remove sealed sender

This commit is contained in:
otsmr 2026-08-31 20:10:43 +02:00
parent bd198acd0c
commit bf802e5540
75 changed files with 1545 additions and 3521 deletions

View file

@ -45,7 +45,6 @@ If you decide to give twonly a try, please keep in mind that it is still in its
- Importing memories from Snapchat
- For Android: Support for [UnifiedPush] (https://unifiedpush.org/)
- For Android: Reproducible builds
- Implementation of [Sealed Sender](https://signal.org/blog/sealed-sender/) (or a similar protocol) to minimize metadata
- Decentralize the server so that anyone can run their own server
## Security Issues

View file

@ -10,11 +10,14 @@ import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.OverlaySettings
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.BitmapOverlay
import androidx.media3.effect.FrameDropEffect
import androidx.media3.effect.OverlayEffect
import androidx.media3.effect.Presentation
import androidx.media3.effect.StaticOverlaySettings
import androidx.media3.effect.TextureOverlay
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
@ -299,13 +302,48 @@ object NativeVideoCodec {
val bitmap = BitmapFactory.decodeFile(overlayPath)
if (bitmap != null) {
val overlays: ImmutableList<TextureOverlay> =
ImmutableList.of(BitmapOverlay.createStaticBitmapOverlay(bitmap))
ImmutableList.of(FullFrameBitmapOverlay(bitmap))
videoEffects.add(OverlayEffect(overlays))
}
}
return Effects(ImmutableList.of(), ImmutableList.copyOf(videoEffects))
}
/**
* Stretches the editor's overlay across the whole frame.
*
* Media3 draws a [BitmapOverlay] at the bitmap's own pixel size, centred:
* the default settings only normalise the bitmap against the frame, they do
* not fit it to one. The overlay is captured at the phone's device pixel
* ratio over the video's on-screen rectangle, so it is comfortably larger
* than the 720p a send is normalised to, and drawn as-is it would appear
* enlarged with whatever the user put near an edge cut off. It shares the
* video's aspect ratio, having been drawn over it, so filling the frame
* does not distort it. The iOS renderer scales its overlay onto the render
* size the same way.
*
* The frame size is only settled once every resolution-changing effect
* ahead of this one has run, which is what [configure] reports, so the
* scale is taken from there rather than from the probed source.
*/
private class FullFrameBitmapOverlay(private val bitmap: Bitmap) : BitmapOverlay() {
private var settings: OverlaySettings = StaticOverlaySettings.Builder().build()
override fun getBitmap(presentationTimeUs: Long): Bitmap = bitmap
override fun configure(videoSize: Size) {
if (bitmap.width <= 0 || bitmap.height <= 0) return
settings = StaticOverlaySettings.Builder()
.setScale(
videoSize.width.toFloat() / bitmap.width,
videoSize.height.toFloat() / bitmap.height,
)
.build()
}
override fun getOverlaySettings(presentationTimeUs: Long): OverlaySettings = settings
}
private fun pollProgress(
handler: Handler,
transformer: Transformer,

View file

@ -72,9 +72,8 @@ class TwonlyNotificationWorker(
manager: NotificationManagerCompat,
addition: NativeNotificationAddition,
): Boolean {
val avatar = addition.avatarPath
?.let(BitmapFactory::decodeFile)
?.let(IconCompat::createWithBitmap)
val avatarBitmap = addition.avatarPath?.let(BitmapFactory::decodeFile)
val avatar = avatarBitmap?.let(IconCompat::createWithBitmap)
val sender = Person.Builder()
.setName(addition.senderName)
.setKey(addition.senderId.toString())
@ -107,6 +106,11 @@ class TwonlyNotificationWorker(
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(addition.conversationId ?: addition.senderId.toString())
// Android only draws the MessagingStyle person icon for conversation
// notifications, which require a long-lived shortcut. Without one the
// standard template is used, where the large icon is the only place
// the sender's avatar can appear.
.apply { avatarBitmap?.let(::setLargeIcon) }
.build()
return try {
manager.notify(nativeNotificationId(addition.notificationId), notification)

View file

@ -4,8 +4,6 @@
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.developer.usernotifications.filtering</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.twonly.runtime</string>

View file

@ -8,9 +8,15 @@ private let runtimeAppGroup = "group.eu.twonly.runtime"
private struct NativeNotificationResponse: Decodable {
let ok: Bool
let batch: NativeNotificationBatch?
let fallback: NativeNotificationPresentation?
let error: String?
}
private struct NativeNotificationPresentation: Decodable {
let title: String
let body: String
}
private struct NativeNotificationBatch: Decodable {
let additions: [NativeNotificationAddition]
let removals: [String]
@ -58,28 +64,34 @@ final class NotificationService: UNNotificationServiceExtension {
private let finishLock = NSLock()
private var hasFinished = false
private var contentHandler: ((UNNotificationContent) -> Void)?
private var fallbackContent: UNNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
fallbackContent = request.content
guard let runtimeDirectory = Self.runtimeDirectory() else {
suppress(reason: "shared runtime directory is unavailable")
deliverFallback(reason: "shared runtime directory is unavailable")
return
}
DispatchQueue.global(qos: .userInitiated).async {
guard let response = Self.processWakeup(runtimeDirectory: runtimeDirectory) else {
self.suppress(reason: "notification worker returned no response")
self.deliverFallback(reason: "notification worker returned no response")
return
}
guard response.ok,
let batch = response.batch,
!batch.additions.isEmpty
else {
self.suppress(reason: response.error ?? "notification worker returned no messages")
guard response.ok else {
self.deliverFallback(
reason: response.error ?? "notification worker failed",
presentation: response.fallback
)
return
}
guard let batch = response.batch, !batch.additions.isEmpty else {
self.deliverFallback(reason: "notification worker returned no messages")
return
}
self.render(batch: batch, original: request.content)
@ -87,7 +99,7 @@ final class NotificationService: UNNotificationServiceExtension {
}
override func serviceExtensionTimeWillExpire() {
suppress(reason: "notification service extension timed out")
deliverFallback(reason: "notification service extension timed out")
}
private func render(batch: NativeNotificationBatch, original: UNNotificationContent) {
@ -208,14 +220,26 @@ final class NotificationService: UNNotificationServiceExtension {
}
hasFinished = true
let handler = contentHandler
fallbackContent = nil
contentHandler = nil
finishLock.unlock()
handler?(content)
}
private func suppress(reason: String) {
NSLog("Suppressing Twonly wake-up notification: \(reason)")
finish(with: UNNotificationContent())
private func deliverFallback(
reason: String,
presentation: NativeNotificationPresentation? = nil
) {
NSLog("Delivering Twonly wake-up fallback notification: \(reason)")
guard let presentation else {
finish(with: fallbackContent ?? UNMutableNotificationContent())
return
}
let content = (fallbackContent?.mutableCopy() as? UNMutableNotificationContent)
?? UNMutableNotificationContent()
content.title = presentation.title
content.body = presentation.body
finish(with: content)
}
private static func runtimeDirectory() -> String? {

View file

@ -8,17 +8,15 @@ import '../user_config.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_callbacks`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `Api`, `FlutterCallbacks`, `Logging`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `Api`, `FlutterCallbacks`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`
Future<void> initFlutterCallbacks({
required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<void> Function(PlatformInt64) apiVerificationSucceeded,
required FutureOr<void> Function(UserConfig) apiUserConfigChanged,
}) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks(
callbackId: callbackId,
loggingGetStreamSink: loggingGetStreamSink,
apiVerificationSucceeded: apiVerificationSucceeded,
apiUserConfigChanged: apiUserConfigChanged,
);

View file

@ -1,14 +0,0 @@
// 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';
// These functions are ignored because they are not marked as `pub`: `set_dart_sink`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `DartWriter`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `flush`, `make_writer`, `write`
Future<String> stripAnsi({required String input}) =>
RustLib.instance.api.crateBridgeCallbacksLogStripAnsi(input: input);

View file

@ -0,0 +1,48 @@
// 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';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `fmt`
/// Adds a Dart record to the Rust-owned application log.
///
/// This only performs a short, serialized append and is synchronous so Dart
/// records cannot be reordered by a collection of unawaited futures.
void writeLog({
required LogLevel level,
required String source,
required String message,
required bool inBackground,
}) => RustLib.instance.api.crateBridgeLoggingWriteLog(
level: level,
source: source,
message: message,
inBackground: inBackground,
);
Future<String> loadLogFile() =>
RustLib.instance.api.crateBridgeLoggingLoadLogFile();
Future<String> readLastLogLines({required int lineCount}) => RustLib
.instance
.api
.crateBridgeLoggingReadLastLogLines(lineCount: lineCount);
Future<void> cleanLogFile() =>
RustLib.instance.api.crateBridgeLoggingCleanLogFile();
/// Truncates `app.log` through its owner instead of unlinking an open file.
Future<bool> clearLogFile() =>
RustLib.instance.api.crateBridgeLoggingClearLogFile();
enum LogLevel {
finest,
fine,
info,
warning,
shout,
}

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@ import 'api/server/prekeys.dart';
import 'bridge.dart';
import 'bridge/api.dart';
import 'bridge/callbacks.dart';
import 'bridge/callbacks/log.dart';
import 'bridge/groups.dart';
import 'bridge/logging.dart';
import 'bridge/user_config.dart';
import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
@ -42,12 +42,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
DateTime dco_decode_Chrono_Utc(dynamic raw);
@protected
FutureOr<RustStreamSink<String>> Function()
dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw);
@ -65,9 +59,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Map<String, List<String>> dco_decode_Map_String_list_String_None(dynamic raw);
@protected
RustStreamSink<String> dco_decode_StreamSink_String_Sse(dynamic raw);
@protected
RustStreamSink<ApiEvent> dco_decode_StreamSink_api_event_Sse(dynamic raw);
@ -243,6 +234,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected
LogLevel dco_decode_log_level(dynamic raw);
@protected
MediaSizeReport dco_decode_media_size_report(dynamic raw);
@ -397,11 +391,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<String> sse_decode_StreamSink_String_Sse(
SseDeserializer deserializer,
);
@protected
RustStreamSink<ApiEvent> sse_decode_StreamSink_api_event_Sse(
SseDeserializer deserializer,
@ -617,6 +606,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected
LogLevel sse_decode_log_level(SseDeserializer deserializer);
@protected
MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer);
@ -779,12 +771,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer);
@protected
void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
FutureOr<RustStreamSink<String>> Function() self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_i_64_Output_unit_AnyhowException(
FutureOr<void> Function(PlatformInt64) self,
@ -812,12 +798,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_String_Sse(
RustStreamSink<String> self,
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_api_event_Sse(
RustStreamSink<ApiEvent> self,
@ -1088,6 +1068,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
@protected
void sse_encode_media_size_report(
MediaSizeReport self,

View file

@ -10,8 +10,8 @@ import 'api/server/prekeys.dart';
import 'bridge.dart';
import 'bridge/api.dart';
import 'bridge/callbacks.dart';
import 'bridge/callbacks/log.dart';
import 'bridge/groups.dart';
import 'bridge/logging.dart';
import 'bridge/user_config.dart';
import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
@ -44,12 +44,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
DateTime dco_decode_Chrono_Utc(dynamic raw);
@protected
FutureOr<RustStreamSink<String>> Function()
dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw);
@ -67,9 +61,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Map<String, List<String>> dco_decode_Map_String_list_String_None(dynamic raw);
@protected
RustStreamSink<String> dco_decode_StreamSink_String_Sse(dynamic raw);
@protected
RustStreamSink<ApiEvent> dco_decode_StreamSink_api_event_Sse(dynamic raw);
@ -245,6 +236,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected
LogLevel dco_decode_log_level(dynamic raw);
@protected
MediaSizeReport dco_decode_media_size_report(dynamic raw);
@ -399,11 +393,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<String> sse_decode_StreamSink_String_Sse(
SseDeserializer deserializer,
);
@protected
RustStreamSink<ApiEvent> sse_decode_StreamSink_api_event_Sse(
SseDeserializer deserializer,
@ -619,6 +608,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected
LogLevel sse_decode_log_level(SseDeserializer deserializer);
@protected
MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer);
@ -781,12 +773,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer);
@protected
void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
FutureOr<RustStreamSink<String>> Function() self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_i_64_Output_unit_AnyhowException(
FutureOr<void> Function(PlatformInt64) self,
@ -814,12 +800,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_String_Sse(
RustStreamSink<String> self,
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_api_event_Sse(
RustStreamSink<ApiEvent> self,
@ -1090,6 +1070,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
@protected
void sse_encode_media_size_report(
MediaSizeReport self,

View file

@ -133,11 +133,6 @@ class UserConfig {
bool storeMediaFilesInGallery;
bool autoStoreAllSendUnlimitedMediaFiles;
bool typingIndicators;
/// Announces to contacts that this account accepts sealed-sender envelopes,
/// and lets this account send them. Both sides have to have it on before
/// anything travels sealed.
bool sealedSenderEnabled;
bool showRestoreFlame;
String? myBestFriendGroupId;
DateTime? signalLastSignedPreKeyUpdated;
@ -203,7 +198,6 @@ class UserConfig {
required this.storeMediaFilesInGallery,
required this.autoStoreAllSendUnlimitedMediaFiles,
required this.typingIndicators,
required this.sealedSenderEnabled,
required this.showRestoreFlame,
this.myBestFriendGroupId,
this.signalLastSignedPreKeyUpdated,
@ -267,7 +261,6 @@ class UserConfig {
storeMediaFilesInGallery.hashCode ^
autoStoreAllSendUnlimitedMediaFiles.hashCode ^
typingIndicators.hashCode ^
sealedSenderEnabled.hashCode ^
showRestoreFlame.hashCode ^
myBestFriendGroupId.hashCode ^
signalLastSignedPreKeyUpdated.hashCode ^
@ -336,7 +329,6 @@ class UserConfig {
autoStoreAllSendUnlimitedMediaFiles ==
other.autoStoreAllSendUnlimitedMediaFiles &&
typingIndicators == other.typingIndicators &&
sealedSenderEnabled == other.sealedSenderEnabled &&
showRestoreFlame == other.showRestoreFlame &&
myBestFriendGroupId == other.myBestFriendGroupId &&
signalLastSignedPreKeyUpdated ==

View file

@ -55,6 +55,7 @@ Future<bool> twonlyMinimumInitialization() async {
dataDir: AppEnvironment.supportDir,
),
);
Log.enableRustSink();
if (!await RustAppDatabase.legacyImportComplete()) {
final legacyFile = File(
'${AppEnvironment.supportDir}/twonly.sqlite',
@ -71,6 +72,9 @@ Future<bool> twonlyMinimumInitialization() async {
await RustAppDatabase.migrateLegacyDatabase();
}
} catch (e) {
// Tracing is initialized before the rest of the Rust context, so even
// failed initialization can persist the buffered startup diagnostics.
Log.enableRustSink();
Log.error(e);
return true;
}
@ -188,7 +192,10 @@ Future<void> postStartupTasks() async {
unawaited(MediaFileService.purgeTempFolder());
// 2. Service initializations
unawaited(RustApi.finishStartedMediaUploads());
unawaitedRustCall(
RustApi.finishStartedMediaUploads(),
'finishStartedMediaUploads',
);
unawaited(
newsService.init().then((_) {
final lastDownload = newsService.lastDownloadedAt;

View file

@ -1,13 +1,11 @@
import 'package:twonly/core/bridge/callbacks.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/callbacks/logging.callbacks.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/services/user.service.dart';
Future<void> initFlutterCallbacksForRust() async {
await initFlutterCallbacks(
callbackId: isolateCallbackId,
loggingGetStreamSink: LoggingCallbacks.getStreamSink,
apiVerificationSucceeded:
KeyVerificationService.handleVerificationSucceeded,
apiUserConfigChanged: UserService.handleRustUserConfigChanged,

View file

@ -1,73 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:logging/logging.dart';
import 'package:twonly/src/utils/log.dart';
/// Matches ANSI escape sequences (CSI sequences, caret notation like ^[[3m or \^[[3m).
final _ansiRegex = RegExp(r'(?:\x1B|\\?\^\[)\[[0-?]*[ -/]*[@-~]');
/// Matches the plain `ShortEventFormatter` output from `rust/src/log.rs`:
/// `HH:MM:SS LEVEL dir/file.rs:12 <fields> <message>`.
final _rustLogLine = RegExp(
r'^\d{2}:\d{2}:\d{2} (TRACE|DEBUG|INFO|WARN|ERROR) +(\S+:\d+) ?(.*)$',
dotAll: true,
);
/// Rust levels mapped onto the `logging` levels the Dart side already prints.
/// `SHOUT` is what [Log.error] uses, so a Rust `ERROR` reads the same as a Dart
/// one.
const Map<String, Level> _levels = {
'TRACE': Level.FINEST,
'DEBUG': Level.FINE,
'INFO': Level.INFO,
'WARN': Level.WARNING,
'ERROR': Level.SHOUT,
};
class LoggingCallbacks {
static Future<RustStreamSink<String>> getStreamSink() async {
final dartLogSink = RustStreamSink<String>();
// `stream` throws until flutter_rust_bridge has serialized the sink for
// Rust, which only happens once this function has returned. Poll for it
// instead of racing; buffered events are replayed on the first listen.
var attempts = 0;
Timer.periodic(const Duration(milliseconds: 100), (timer) {
attempts++;
try {
dartLogSink.stream.listen(_handleRustLog);
timer.cancel();
} catch (_) {
// Stream not yet initialized.
if (attempts >= 100) {
timer.cancel();
Log.warn('Rust log sink never became available.');
}
}
});
return dartLogSink;
}
@visibleForTesting
static void handleRustLog(String log) => _handleRustLog(log);
static void _handleRustLog(String log) {
final sanitizedLog = log.replaceAll(_ansiRegex, '');
final match = _rustLogLine.firstMatch(sanitizedLog);
if (match == null) {
// Not a formatted event (panic output, a continuation line, ...).
Log.warn(sanitizedLog);
return;
}
// The level and the `file.rs:line` origin belong in the record itself, not
// repeated inside the message: the Dart call site here says nothing useful.
Log.forward(
level: _levels[match.group(1)] ?? Level.INFO,
source: match.group(2)!,
messageInput: match.group(3),
);
}
}

View file

@ -56,10 +56,6 @@ enum MessageActionType {
openedAt,
ackByUserAt,
ackByServerAt,
/// Present when this recipient's copy left as a sealed-sender envelope. The
/// transport is per recipient, so in a group only some members may have one.
sealedSenderAt,
}
@DataClassName('MessageAction')

View file

@ -1832,36 +1832,18 @@ abstract class AppLocalizations {
/// **'Waiting for internet'**
String get waitingForInternet;
/// No description provided for @sealedSenderTransportSealed.
///
/// In en, this message translates to:
/// **'Sealed sender'**
String get sealedSenderTransportSealed;
/// No description provided for @sealedSenderTransportStandard.
///
/// In en, this message translates to:
/// **'Standard'**
String get sealedSenderTransportStandard;
/// No description provided for @settingsSealedSender.
///
/// In en, this message translates to:
/// **'Sealed Sender'**
String get settingsSealedSender;
/// No description provided for @settingsSealedSenderSubtitle.
///
/// In en, this message translates to:
/// **'Hides who you are from the server when sending. Both you and your contact need it turned on, so turning it off also stops your contacts from sending to you sealed.'**
String get settingsSealedSenderSubtitle;
/// No description provided for @editHistory.
///
/// In en, this message translates to:
/// **'Edit history'**
String get editHistory;
/// No description provided for @fileSize.
///
/// In en, this message translates to:
/// **'File size'**
String get fileSize;
/// No description provided for @archivedChats.
///
/// In en, this message translates to:
@ -2252,6 +2234,12 @@ abstract class AppLocalizations {
/// **'Messages from other users.'**
String get notificationCategoryMessageDesc;
/// No description provided for @notificationConnectionFallback.
///
/// In en, this message translates to:
/// **'You may have new messages.'**
String get notificationConnectionFallback;
/// No description provided for @groupContextMenuDeleteGroup.
///
/// In en, this message translates to:

View file

@ -980,22 +980,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get waitingForInternet => 'Warten auf Internet';
@override
String get sealedSenderTransportSealed => 'Sealed Sender';
@override
String get sealedSenderTransportStandard => 'Standard';
@override
String get settingsSealedSender => 'Sealed Sender';
@override
String get settingsSealedSenderSubtitle =>
'Verbirgt beim Senden vor dem Server, wer du bist. Sowohl du als auch dein Kontakt müssen es aktiviert haben; deaktivierst du es, senden deine Kontakte auch nicht mehr versiegelt an dich.';
@override
String get editHistory => 'Bearbeitungshistorie';
@override
String get fileSize => 'Dateigröße';
@override
String get archivedChats => 'Archivierte Chats';
@ -1254,6 +1244,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get notificationCategoryMessageDesc =>
'Nachrichten von anderen Benutzern.';
@override
String get notificationConnectionFallback =>
'Du könntest neue Nachrichten haben.';
@override
String get groupContextMenuDeleteGroup =>
'Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.';

View file

@ -973,22 +973,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get waitingForInternet => 'Waiting for internet';
@override
String get sealedSenderTransportSealed => 'Sealed sender';
@override
String get sealedSenderTransportStandard => 'Standard';
@override
String get settingsSealedSender => 'Sealed Sender';
@override
String get settingsSealedSenderSubtitle =>
'Hides who you are from the server when sending. Both you and your contact need it turned on, so turning it off also stops your contacts from sending to you sealed.';
@override
String get editHistory => 'Edit history';
@override
String get fileSize => 'File size';
@override
String get archivedChats => 'Archived chats';
@ -1246,6 +1236,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get notificationCategoryMessageDesc => 'Messages from other users.';
@override
String get notificationConnectionFallback => 'You may have new messages.';
@override
String get groupContextMenuDeleteGroup =>
'This will permanently delete all messages in this chat.';

View file

@ -42,7 +42,10 @@ class ApiService {
if (AppState.isInBackgroundTask) {
await rust_api.RustApi.reuploadPendingMedia();
} else if (!AppState.isAppInBackground) {
unawaited(rust_api.RustApi.reuploadPendingMedia());
unawaitedRustCall(
rust_api.RustApi.reuploadPendingMedia(),
'reuploadPendingMedia',
);
twonlyDB.markUpdated();
// resetUserDiscoveryRequestUpdates();

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:twonly/src/model/error_code.dart';
import 'package:twonly/src/utils/log.dart';
@ -27,3 +29,18 @@ Future<Result<T, ErrorCode>> rustApiResult<T>(Future<T> request) async {
return Result.error(ErrorCode.InternalError);
}
}
/// Starts a Rust API call that nothing waits on.
///
/// Anything crossing the bridge can fail on transport alone the WebSocket is
/// still connecting at startup, or it drops mid-request and a rejected future
/// with no listener surfaces as an unhandled exception in the root zone. These
/// calls are all retried by Rust or repeated by the next tick, so the failure
/// only has to be logged.
void unawaitedRustCall(Future<void> request, String description) {
unawaited(
request.catchError((Object error) {
Log.warn('$description failed', error);
}),
);
}

View file

@ -108,10 +108,6 @@ Future<void> runMigrations() async {
await UserService.update((u) => u.appVersion = 118);
}
// The server only delivers sealed-sender payloads to clients reporting at
// least this version, so the bump is what announces support for them. The
// `sealedSenderEnabled` setting itself needs no migration: an existing
// user.json without the key falls back to the enabled default.
if (userService.currentUser.appVersion < 119) {
await UserService.update((u) => u.appVersion = 119);
}

View file

@ -1,22 +1,29 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/core/bridge/logging.dart' as rust_logging;
import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
class Log {
static bool _isInitialized = false;
static bool _rustSinkReady = false;
static const int _maxBufferedRecords = 1000;
static final List<LogRecord> _bufferedRecords = [];
static void init() {
if (_isInitialized) return;
_isInitialized = true;
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) async {
unawaited(_writeLogToFile(record));
Logger.root.onRecord.listen((record) {
if (_rustSinkReady) {
if (!_writeLogToRust(record)) {
_rustSinkReady = false;
_buffer(record);
}
} else {
_buffer(record);
}
if (!kReleaseMode) {
if (!Platform.environment.containsKey('FLUTTER_TEST') ||
record.level >= Level.WARNING) {
@ -29,6 +36,54 @@ class Log {
});
}
/// Enables the Rust-owned app.log and flushes records emitted before Rust
/// initialization completed.
static void enableRustSink() {
if (_rustSinkReady) return;
_rustSinkReady = true;
final pending = List<LogRecord>.of(_bufferedRecords);
_bufferedRecords.clear();
for (var i = 0; i < pending.length; i++) {
if (_writeLogToRust(pending[i])) continue;
_rustSinkReady = false;
for (var j = i; j < pending.length; j++) {
_buffer(pending[j]);
}
break;
}
}
static void _buffer(LogRecord record) {
if (_bufferedRecords.length == _maxBufferedRecords) {
_bufferedRecords.removeAt(0);
}
_bufferedRecords.add(record);
}
static bool _writeLogToRust(LogRecord record) {
try {
rust_logging.writeLog(
level: switch (record.level) {
>= Level.SHOUT => rust_logging.LogLevel.shout,
>= Level.WARNING => rust_logging.LogLevel.warning,
>= Level.INFO => rust_logging.LogLevel.info,
>= Level.FINE => rust_logging.LogLevel.fine,
_ => rust_logging.LogLevel.finest,
},
source: record.loggerName,
message: record.message,
inBackground: AppState.isInBackgroundTask,
);
return true;
} catch (error) {
if (!kReleaseMode) {
// ignore: avoid_print
print('Could not forward log record to Rust: $error');
}
return false;
}
}
static String filterLogMessage(String msg) {
if (msg.contains('SqliteException')) {
// Do not log data which would be inserted into the DB.
@ -77,119 +132,22 @@ class Log {
final message = filterLogMessage('$messageInput');
Logger(_getCallerSourceCodeFilename()).fine(message, error, stackTrace);
}
/// Re-emits a record that was produced outside Dart, keeping the origin's
/// level and source location. Deriving either from the Dart call site would
/// only ever point back at the forwarding code.
static void forward({
required Level level,
required String source,
required Object? messageInput,
}) {
Logger(source).log(level, filterLogMessage('$messageInput'));
}
}
Future<String> loadLogFile() async {
return _protectFileAccess(() async {
final logFile = File('${AppEnvironment.supportDir}/app.log');
if (logFile.existsSync()) {
return logFile.readAsString();
} else {
return 'Log file does not exist.';
}
});
return rust_logging.loadLogFile();
}
Future<String> readLast1000Lines() async {
return _protectFileAccess(() async {
final file = File('${AppEnvironment.supportDir}/app.log');
if (!file.existsSync()) return '';
final all = await file.readAsLines();
final start = all.length > 1000 ? all.length - 1000 : 0;
return all.sublist(start).join('\n');
});
}
final Mutex _logMutex = Mutex();
Future<T> _protectFileAccess<T>(Future<T> Function() action) async {
return exclusiveAccess(
lockName: 'app.log',
action: action,
mutex: _logMutex,
);
}
Future<void> _writeLogToFile(LogRecord record) async {
final logFile = File('${AppEnvironment.supportDir}/app.log');
final logMessage =
'${clock.now()} ${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}\n';
return _protectFileAccess(() async {
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
final raf = await logFile.open(mode: FileMode.writeOnlyAppend);
try {
await raf.writeString(logMessage);
await raf.flush();
} catch (e) {
// ignore: avoid_print
print('Error during file access: $e');
} finally {
await raf.close();
}
});
return rust_logging.readLastLogLines(lineCount: 1000);
}
Future<void> cleanLogFile() async {
return _protectFileAccess(() async {
final logFile = File('${AppEnvironment.supportDir}/app.log');
if (!logFile.existsSync()) {
return;
}
final lines = await logFile.readAsLines();
final twoWeekAgo = clock.now().subtract(const Duration(days: 3));
var keepStartIndex = -1;
for (var i = 0; i < lines.length; i += 100) {
if (lines[i].length >= 19) {
final date = DateTime.tryParse(lines[i].substring(0, 19));
if (date != null && date.isAfter(twoWeekAgo)) {
keepStartIndex = i;
break;
}
}
}
if (keepStartIndex == 0) return;
if (keepStartIndex == -1) {
await logFile.writeAsString('');
return;
}
final remaining = lines.sublist(keepStartIndex);
final sink = logFile.openWrite()..writeAll(remaining, '\n');
await sink.close();
});
return rust_logging.cleanLogFile();
}
Future<bool> deleteLogFile() async {
return _protectFileAccess(() async {
final logFile = File('${AppEnvironment.supportDir}/app.log');
if (logFile.existsSync()) {
await logFile.delete();
return true;
}
return false;
});
return rust_logging.clearLogFile();
}
String _getCallerSourceCodeFilename() {

View file

@ -249,8 +249,20 @@ class MainCameraController {
}
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
// Stabilization buys its steadiness with a margin of sensor pixels to
// warp against, so it always narrows the field of view. On Android
// level2 maps to Camera2's PREVIEW_STABILIZATION, which narrows the
// preview by the same amount as the recording, so the viewfinder
// shows what gets sent; level1 is plain VIDEO_STABILIZATION_MODE_ON,
// which crops the recording alone and leaves the preview wider than
// the result. On iOS level2 is AVFoundation's `.cinematic`, which
// crops harder than the `.standard` level1 maps to without matching
// the preview any better, so iOS stays where it was. Both fall back
// down the levels, and finally to off, on a camera without the mode.
await controller.setVideoStabilizationMode(
VideoStabilizationMode.level1,
Platform.isAndroid
? VideoStabilizationMode.level2
: VideoStabilizationMode.level1,
);
if (sessionId != _cameraSessionId) {
unawaited(controller.dispose());

View file

@ -309,7 +309,7 @@ class _ShareImageView extends State<ShareImageView> {
});
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
unawaited(
unawaitedRustCall(
RustApi.sendMediaToGroups(
mediaId:
widget.mediaFileService.mediaFile.mediaId,
@ -317,6 +317,7 @@ class _ShareImageView extends State<ShareImageView> {
additionalMessageData: widget.additionalData
?.writeToBuffer(),
),
'sendMediaToGroups',
);
if (context.mounted) {

View file

@ -196,13 +196,19 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}
if (userService.currentUser.typingIndicators) {
unawaited(RustApi.sendTyping(groupId: widget.groupId, isTyping: false));
_nextTypingIndicator = Timer.periodic(chatOpenPingInterval, (_) async {
unawaitedRustCall(
RustApi.sendTyping(groupId: widget.groupId, isTyping: false),
'sendTyping',
);
_nextTypingIndicator = Timer.periodic(chatOpenPingInterval, (_) {
// A typing announcement refreshes the contact's chat-open state as
// well, so pinging while the composer is active would spend a second
// message only to clear the typing flag that composer just set.
if (_isViewActive() && !_composing.value) {
await RustApi.sendTyping(groupId: widget.groupId, isTyping: false);
unawaitedRustCall(
RustApi.sendTyping(groupId: widget.groupId, isTyping: false),
'sendTyping',
);
}
});
}

View file

@ -96,13 +96,16 @@ class _MessageInputState extends State<MessageInput> {
}
widget.textFieldFocus.addListener(_handleTextFocusChange);
if (userService.currentUser.typingIndicators) {
_nextTypingIndicator = Timer.periodic(typingIndicatorInterval, (_) async {
_nextTypingIndicator = Timer.periodic(typingIndicatorInterval, (_) {
final composing = _isComposing;
widget.composing.value = composing;
if (composing) {
await RustApi.sendTyping(
unawaitedRustCall(
RustApi.sendTyping(
groupId: widget.group.groupId,
isTyping: true,
),
'sendTyping',
);
}
});
@ -161,8 +164,9 @@ class _MessageInputState extends State<MessageInput> {
userService.currentUser.typingIndicators &&
widget.textFieldFocus.hasFocus) {
widget.composing.value = true;
unawaited(
unawaitedRustCall(
RustApi.sendTyping(groupId: widget.group.groupId, isTyping: true),
'sendTyping',
);
}
}

View file

@ -11,10 +11,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages.view.dart';
/// How often a composing user re-announces that it is typing.
///
/// Every announcement is a message, and a message sent sealed costs a Privacy
/// Pass token out of a daily quota. At the one-second cadence this used to run
/// at, a few minutes of typing spent a whole day's worth, after which every
/// later message real ones included fell back to the named transport.
/// Keep presence traffic modest while still making the indicator feel live.
const typingIndicatorInterval = Duration(seconds: 4);
/// How long a received typing announcement counts for. Comfortably longer than

View file

@ -357,8 +357,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
Log.info(
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
);
unawaited(
unawaitedRustCall(
RustApi.downloadDone(token: currentMediaLocal.mediaFile.downloadToken!),
'downloadDone',
);
if (currentMediaLocal.mediaFile.type == MediaType.video) {
@ -614,12 +615,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
void _sendTextMessage() {
if (textMessageController.text.isNotEmpty) {
unawaited(
unawaitedRustCall(
RustApi.insertAndSendText(
groupId: widget.group.groupId,
text: textMessageController.text,
quoteMessageId: currentMessage!.messageId,
),
'insertAndSendText',
);
textMessageController.clear();
}

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@ -9,6 +10,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
@ -35,10 +37,13 @@ class _MessageInfoViewState extends State<MessageInfoView> {
StreamSubscription<List<(MessageAction, Contact)>>? actionsStream;
StreamSubscription<List<MessageHistory>>? historyStream;
StreamSubscription<List<(GroupMember, Contact)>>? groupMemberStream;
StreamSubscription<MediaFile?>? mediaFileStream;
List<(MessageAction, Contact)> messageActions = [];
List<MessageHistory> messageHistory = [];
List<(GroupMember, Contact)> groupMembers = [];
MediaFile? mediaFile;
int? mediaSizeInBytes;
@override
void initState() {
@ -51,6 +56,7 @@ class _MessageInfoViewState extends State<MessageInfoView> {
actionsStream?.cancel();
historyStream?.cancel();
groupMemberStream?.cancel();
mediaFileStream?.cancel();
super.dispose();
}
@ -84,6 +90,35 @@ class _MessageInfoViewState extends State<MessageInfoView> {
messageHistory = update;
});
});
final mediaId = widget.message.mediaId;
if (mediaId != null) {
final streamMedia = twonlyDB.mediaFilesDao.watchMedia(mediaId);
mediaFileStream = streamMedia.listen((update) {
if (!mounted) return;
setState(() {
mediaFile = update;
mediaSizeInBytes = update == null ? null : mediaSize(update);
});
});
}
}
/// Rust records the size when it writes the plaintext, but media that was
/// sent or received before it did so only carries a size once it was stored,
/// so a copy that is still on disk is measured directly.
int? mediaSize(MediaFile media) {
if (media.sizeInBytes != null) return media.sizeInBytes;
final service = MediaFileService(media);
for (final file in [
service.storedPath,
service.tempPath,
service.originalPath,
]) {
final stat = file.statSync();
if (stat.type == FileSystemEntityType.file) return stat.size;
}
return null;
}
List<Widget> getReceivedColumns(BuildContext context) {
@ -113,12 +148,6 @@ class _MessageInfoViewState extends State<MessageInfoView> {
t.$1.type == MessageActionType.openedAt &&
t.$2.userId == groupMember.$2.userId,
);
final sealedSender = messageActions.firstWhereOrNull(
(t) =>
t.$1.type == MessageActionType.sealedSenderAt &&
t.$2.userId == groupMember.$2.userId,
);
var actionTypeText = context.lang.waitingForInternet;
var actionAt = widget.message.createdAt;
if (ackByServer != null) {
@ -165,23 +194,6 @@ class _MessageInfoViewState extends State<MessageInfoView> {
Text(actionTypeText),
],
),
// The transport is decided per recipient, so it is only known
// once this member's copy has actually left the device.
if (ackByServer != null) ...[
const SizedBox(width: 10),
Tooltip(
message: sealedSender != null
? context.lang.sealedSenderTransportSealed
: context.lang.sealedSenderTransportStandard,
child: FaIcon(
sealedSender != null
? FontAwesomeIcons.solidEnvelope
: FontAwesomeIcons.envelope,
size: 13,
color: Theme.of(context).hintColor,
),
),
],
],
),
),
@ -228,6 +240,10 @@ class _MessageInfoViewState extends State<MessageInfoView> {
Text(
'${context.lang.received}: ${friendlyDateTime(context, widget.message.ackByServer!)}',
),
if (mediaSizeInBytes != null)
Text(
'${context.lang.fileSize}: ${formatBytes(mediaSizeInBytes!)}',
),
if (userService.currentUser.isDeveloper)
GestureDetector(
onTap: () async {

View file

@ -335,7 +335,10 @@ class _StorageContentsViewState extends State<StorageContentsView> {
try {
if (deleteCompletely) {
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
unawaited(RustApi.deleteMemory(mediaId: file.mediaId));
unawaitedRustCall(
RustApi.deleteMemory(mediaId: file.mediaId),
'deleteMemory',
);
await MediaFileService(file).fullMediaRemoval();
} else {
MediaFileService(file).storedPath.deleteSync();
@ -384,7 +387,10 @@ class _StorageContentsViewState extends State<StorageContentsView> {
for (final file in selectedFiles) {
if (deleteCompletely) {
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
unawaited(RustApi.deleteMemory(mediaId: file.mediaId));
unawaitedRustCall(
RustApi.deleteMemory(mediaId: file.mediaId),
'deleteMemory',
);
await MediaFileService(file).fullMediaRemoval();
} else {
MediaFileService(file).storedPath.deleteSync();

View file

@ -40,13 +40,6 @@ class _PrivacyViewState extends State<PrivacyView> {
setState(() {});
}
Future<void> toggleSealedSender() async {
await UserService.update((u) {
u.sealedSenderEnabled = !u.sealedSenderEnabled;
});
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -100,16 +93,6 @@ class _PrivacyViewState extends State<PrivacyView> {
),
),
const Divider(),
ListTile(
title: Text(context.lang.settingsSealedSender),
subtitle: Text(context.lang.settingsSealedSenderSubtitle),
onTap: toggleSealedSender,
trailing: Switch.adaptive(
value: userService.currentUser.sealedSenderEnabled,
onChanged: (a) => toggleSealedSender(),
),
),
const Divider(),
ListTile(
title: Text(context.lang.settingsScreenLock),
subtitle: Text(context.lang.settingsScreenLockSubtitle),

View file

@ -186,10 +186,10 @@ packages:
dependency: "direct main"
description:
name: camera
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f"
url: "https://pub.dev"
source: hosted
version: "0.12.0+1"
version: "0.12.0+2"
camera_android_camerax:
dependency: "direct main"
description:

532
rust/Cargo.lock generated
View file

@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@ -256,30 +256,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.72.1"
@ -326,33 +308,13 @@ dependencies = [
"zeroize",
]
[[package]]
name = "blind-rsa-signatures"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c8e1ec3966bafbe115ad484420b260f5fabf88528e4ef8cd3024ffedb50e46"
dependencies = [
"crypto-bigint 0.7.5",
"crypto-primes",
"ct-codecs",
"derive-new",
"derive_more",
"digest 0.11.3",
"hmac-sha256",
"hmac-sha512",
"rand 0.10.2",
"rand_core 0.10.1",
"rsa",
"serde",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@ -466,7 +428,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
"nom",
]
[[package]]
@ -605,12 +567,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@ -623,15 +579,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -733,15 +680,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
@ -776,39 +714,13 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-bigint"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
dependencies = [
"cpubits",
"ctutils",
"num-traits",
"rand_core 0.10.1",
"serdect",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array 0.14.7",
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -823,23 +735,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "crypto-primes"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a"
dependencies = [
"crypto-bigint 0.7.5",
"libm",
"rand_core 0.10.1",
]
[[package]]
name = "ct-codecs"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39"
[[package]]
name = "ctr"
version = "0.9.2"
@ -867,23 +762,6 @@ dependencies = [
"cmov",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto 0.2.9",
"rand_core 0.6.4",
"rustc_version",
"serde",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
@ -894,7 +772,7 @@ dependencies = [
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"fiat-crypto",
"rand_core 0.10.1",
"rustc_version",
"subtle",
@ -1011,44 +889,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]]
name = "der"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
dependencies = [
"const-oid 0.10.2",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive-new"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive-where"
version = "1.6.1"
@ -1086,12 +926,10 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
"unicode-xid",
]
[[package]]
@ -1112,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
@ -1149,24 +987,6 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct 0.2.0",
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff",
"generic-array 0.14.7",
"group",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
@ -1273,22 +1093,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
@ -1532,18 +1336,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
name = "generic-array"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
dependencies = [
"rustversion",
"serde_core",
"typenum",
]
[[package]]
@ -1627,17 +1419,6 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "hash32"
version = "0.2.1"
@ -1795,24 +1576,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "hmac-sha256"
version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "hmac-sha512"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "hpke-rs"
version = "0.7.0"
@ -2139,7 +1902,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@ -2427,7 +2190,7 @@ name = "libsignal-core"
version = "0.1.0"
source = "git+https://github.com/signalapp/libsignal?rev=44a6dd8fc9f9d6903b32842b80b3894a75678418#44a6dd8fc9f9d6903b32842b80b3894a75678418"
dependencies = [
"curve25519-dalek 5.0.0",
"curve25519-dalek",
"derive_more",
"displaydoc",
"libsignal-debug",
@ -2654,15 +2417,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -2682,12 +2436,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
@ -2823,17 +2571,6 @@ dependencies = [
"log",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -2885,15 +2622,6 @@ dependencies = [
"hmac 0.13.0",
]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -2923,26 +2651,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.8.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
dependencies = [
"der 0.8.1",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.1",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@ -3038,12 +2746,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -3083,40 +2785,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "privacypass"
version = "0.2.0-pre.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5da5a5d50ec311674442d7388689094d044d47c783a2f3dd5cf4ef572be92a2"
dependencies = [
"async-trait",
"base64",
"blind-rsa-signatures",
"generic-array 1.4.5",
"http",
"log",
"nom 8.0.0",
"p384",
"rand 0.10.2",
"serde",
"sha2 0.10.9",
"subtle",
"thiserror 2.0.19",
"tls_codec",
"trait-variant",
"typenum",
"voprf-ng",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@ -3521,26 +3189,6 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rsa"
version = "0.10.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
dependencies = [
"const-oid 0.10.2",
"crypto-bigint 0.7.5",
"crypto-primes",
"digest 0.11.3",
"pkcs1",
"pkcs8",
"rand_core 0.10.1",
"serde",
"serdect",
"signature",
"spki",
"zeroize",
]
[[package]]
name = "rust_lib_twonly"
version = "0.1.0"
@ -3567,11 +3215,9 @@ dependencies = [
"libc",
"libsignal-protocol",
"libsqlite3-sys",
"p384",
"paste",
"postcard",
"pretty_env_logger",
"privacypass",
"prost",
"prost-build",
"rand 0.8.7",
@ -3591,7 +3237,6 @@ dependencies = [
"tokio-tungstenite",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
@ -3757,19 +3402,6 @@ dependencies = [
"sha2 0.11.0",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct 0.2.0",
"der 0.7.10",
"generic-array 0.14.7",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@ -3854,16 +3486,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serdect"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct 1.0.0",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
@ -3965,16 +3587,6 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
dependencies = [
"digest 0.11.3",
"rand_core 0.10.1",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@ -4045,23 +3657,13 @@ dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
"der 0.8.1",
]
[[package]]
name = "spqr"
version = "1.5.3"
source = "git+https://github.com/signalapp/SparsePostQuantumRatchet.git?tag=v1.5.3#fd320484dcec89004021e6fdc7481825f5f261fa"
dependencies = [
"cpufeatures 0.3.0",
"curve25519-dalek 5.0.0",
"curve25519-dalek",
"displaydoc",
"hax-lib",
"hkdf 0.13.0",
@ -4181,7 +3783,7 @@ dependencies = [
"either",
"futures-core",
"futures-util",
"generic-array 0.14.7",
"generic-array",
"log",
"percent-encoding",
"serde",
@ -4324,12 +3926,6 @@ dependencies = [
"siphasher",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.119"
@ -4452,36 +4048,6 @@ dependencies = [
"num_cpus",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@ -4533,27 +4099,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.53.1"
@ -4732,19 +4277,6 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.19",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@ -4795,17 +4327,6 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "trait-variant"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@ -4892,24 +4413,12 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
@ -5016,25 +4525,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voprf-ng"
version = "0.6.0-pre.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bed8944bdc5dfafa7b1c434c086b3f6281dfb26715bd8e42d58d5e4434cc0307"
dependencies = [
"curve25519-dalek 4.1.3",
"derive-where",
"digest 0.10.7",
"displaydoc",
"elliptic-curve",
"generic-array 1.4.5",
"rand_core 0.10.1",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@ -5418,7 +4908,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6"
dependencies = [
"curve25519-dalek 5.0.0",
"curve25519-dalek",
"rand_core 0.10.1",
"zeroize",
]

View file

@ -36,7 +36,6 @@ sha2 = "0.10.8"
aes-gcm = "0.10.3"
chacha20poly1305 = "0.10.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2.5"
paste = "1.0.15"
serde = { version = "1.0", features = ["derive"] }
zeroize = { version = "1.8", features = ["derive"] }
@ -68,11 +67,6 @@ rustls = { version = "0.23.43", default-features = false, features = [
] }
tokio-util = "0.7.16"
bon = "3.9.3"
privacypass = "=0.2.0-pre.3"
p384 = { version = "0.13.0", default-features = false, features = [
"hash2curve",
"voprf",
] }
stream-tungstenite = "0.6.1"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
webp = "0.3.1"

View file

@ -28,7 +28,7 @@ fn main() -> Result<()> {
prost_build::compile_protos(&["models/user_discovery.proto"], &["src/"])?;
let client_proto_root = "src/api/proto/client";
let client_protos = [
"src/api/proto/client/sealed_sender.proto",
"src/api/proto/client/transport.proto",
"src/api/proto/client/groups.proto",
"src/api/proto/client/messages.proto",
"src/api/proto/client/data.proto",

View file

@ -28,7 +28,6 @@ use crate::api::proto::{client_to_server, server_to_client};
use crate::context::Context;
use crate::database::app::tables::{Contact, Group, Receipt};
use crate::error::{Result, TwonlyError};
use crate::sealed_sender::SealedSender;
use crate::services::contacts::ContactService;
use client_to_server::response::{ok, Response};
use prost::Message as _;
@ -67,20 +66,6 @@ pub(crate) async fn handle_server_message(
Kind::PendingMessagesV2(batch) => {
return Ok(acknowledge_pending_messages(ctx, batch).await);
}
Kind::SealedSenderMessage(message) => {
if let Err(error) = handle_sealed_message(ctx, message.body).await {
tracing::warn!("failed to process sealed-sender message: {error}");
}
ok::Ok::None(true)
}
Kind::SealedSenderMessages(messages) => {
for message in messages.messages {
if let Err(error) = handle_sealed_message(ctx, message.body).await {
tracing::warn!("failed to process sealed-sender message in batch: {error}");
}
}
ok::Ok::None(true)
}
Kind::MailboxDrained(_) => {
ctx.mark_mailbox_drained();
ok::Ok::None(true)
@ -168,16 +153,6 @@ pub(crate) async fn handle_new_server_message(
handle_decoded_server_message(ctx, server_message.from_user_id, message).await
}
pub(crate) async fn handle_sealed_message(ctx: &Arc<Context>, bytes: Vec<u8>) -> Result<()> {
let payload = SealedSender::decrypt(&bytes, ctx.as_ref()).await?;
let message = payload
.message
.ok_or_else(|| TwonlyError::Generic("sealed message contains no client message".into()))?;
handle_decoded_server_message(ctx, payload.from_user_id, message).await
}
pub(crate) async fn handle_request_new_pqc_prekeys(
ctx: &Arc<Context>,
) -> Result<client_to_server::response::ok::Ok> {
@ -474,10 +449,6 @@ async fn handle_encrypted_inner(
contact::check_for_profile_update(t, from_user_id, &content).await?;
if let Some(enabled) = content.sender_accepts_sealed_sender {
Contact::set_sealed_sender_enabled(t, from_user_id, enabled).await?;
}
if content.ask_for_friend_promotions == Some(true) {
Contact::update_ask_for_friend_promotions(t, from_user_id).await?;
}

View file

@ -11,7 +11,6 @@ use crate::context::Context;
use crate::database::app::tables::{Contact, MediaFile, NewReceipt, Receipt};
use crate::error::{Result, TwonlyError};
use crate::services::contacts::ContactService;
use crate::services::sealed_sender::SealedSenderService;
use crate::utils::new_uuid_v4;
use prost::Message as ProstMessage;
use proto::encrypted_content::error_messages::Type;
@ -296,12 +295,9 @@ async fn encrypt_v2_with_session_recovery(
}
pub(crate) struct PreparedQueuedReceipt {
pub receipt_id: String,
pub contact_id: i64,
pub message_id: Option<String>,
pub contact_will_sends_receipt: i64,
/// The fully prepared client message. A sealed-sender envelope wraps this
/// value, while the named transport sends its encoding.
pub message: proto::Message,
pub wake_receiver: bool,
}
@ -409,7 +405,6 @@ async fn prepare_queued_receipt_from_row(
}
Ok(PreparedQueuedReceipt {
receipt_id: receipt_id.to_owned(),
contact_id: row.contact_id,
message_id: row.message_id,
contact_will_sends_receipt: row.contact_will_sends_receipt,
@ -474,9 +469,6 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
Err(error) => return Err(error),
};
let sent_sealed = SealedSenderService::try_send(ctx, &receipt).await?;
if !sent_sealed {
match Server::send_text_message(
ctx,
receipt.contact_id,
@ -493,7 +485,6 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
)));
}
}
}
let mut t = app_db.pool.begin().await?;
if let Some(message_id) = receipt.message_id {
@ -510,33 +501,6 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
.execute(&mut *t)
.await?;
// The transport is per recipient: in a group one member may accept
// sealed envelopes while another still needs the named send. Recording
// it as an action keeps that distinction visible per member.
if sent_sealed {
sqlx::query!(
r#"
INSERT INTO message_actions(message_id, contact_id, type)
VALUES (?, ?, 'sealedSenderAt')
ON CONFLICT(message_id, contact_id, type)
DO UPDATE SET action_at = CAST(strftime('%s', 'now') AS INTEGER)
"#,
message_id,
receipt.contact_id,
)
.execute(&mut *t)
.await?;
} else {
sqlx::query!(
"DELETE FROM message_actions
WHERE message_id = ? AND contact_id = ? AND type = 'sealedSenderAt'",
message_id,
receipt.contact_id,
)
.execute(&mut *t)
.await?;
}
// `message_actions` keeps the per-recipient acknowledgement used by
// group chats. The message row also carries the aggregate value used
// by message bubbles and chat previews to leave the "sending" state.

View file

@ -23,10 +23,6 @@ pub(crate) async fn decorate_content(
};
content.sender_profile_counter = Some(config.avatar_counter);
// Announced on every content, including when it is off: a contact that
// switches the feature off has to stop receiving sealed envelopes.
content.sender_accepts_sealed_sender = Some(config.sealed_sender_enabled);
if config.ask_for_friend_promotions {
let database = ctx.app_db.read().await.clone();
let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1")

View file

@ -7,7 +7,6 @@ pub(crate) mod groups;
pub mod messages;
pub mod proto;
pub(super) mod runtime;
pub(crate) mod sealed_sender;
pub(super) mod server;
#[doc(hidden)]

View file

@ -1,18 +1,6 @@
syntax = "proto3";
package http_requests;
message SealedSenderMessageRequest {
reserved 4;
int64 recipient_user_id = 1;
bytes sealed_sender_message = 2;
bytes privacy_pass_token = 3;
bool wake_receiver = 5;
}
message SealedSenderMessageResponse {
string message_id = 1;
}
message RequestUploadSlots {
repeated string locally_known_slot_ids = 1;
}

View file

@ -259,16 +259,11 @@ message ApplicationData {
message DisableMemoriesBackup {}
message GetPrivacyPassParameters {}
message IssuePrivacyPassTokens {
repeated bytes token_requests = 1;
}
// Asks the server to (re)start a mailbox drain for the authenticated user.
// The server responds immediately; messages arrive as PendingMessagesV2.
message RequestPendingMessages {}
reserved 41, 42;
oneof ApplicationData {
TextMessage textMessage = 1;
GetUserByUsername getUserByUsername = 2;
@ -298,8 +293,6 @@ message ApplicationData {
DeleteMemory delete_memory = 38;
DisableMemoriesBackup disable_memories_backup = 39;
UploadPqcPreKeys upload_pqc_prekeys = 40;
GetPrivacyPassParameters get_privacy_pass_parameters = 41;
IssuePrivacyPassTokens issue_privacy_pass_tokens = 42;
RequestPendingMessages request_pending_messages = 43;
}
}

View file

@ -45,5 +45,5 @@ enum ErrorCode {
ForegroundSessionConnected = 1036;
NoRecoveryData = 1037;
InvalidRecoveryData = 1038;
PrivacyPassQuotaExhausted = 1039;
reserved 1039;
}

View file

@ -11,6 +11,7 @@ message ServerToClient {
message V0 {
uint64 seq = 1;
reserved 9, 10;
oneof Kind {
Response response = 2;
NewMessage newMessage = 3;
@ -18,8 +19,6 @@ message V0 {
bool RequestNewPreKeys = 4;
bool RequestNewPqcPreKeys = 8;
error.ErrorCode error = 6;
SealedSenderMessage sealedSenderMessage = 9;
SealedSenderMessages sealedSenderMessages = 10;
bool mailboxDrained = 11;
PendingMessagesV2 pendingMessagesV2 = 12;
}
@ -47,15 +46,6 @@ message PendingMessagesV2 {
repeated PendingMessageV2 messages = 1;
}
message SealedSenderMessage {
string message_id = 1;
bytes body = 2;
}
message SealedSenderMessages {
repeated SealedSenderMessage messages = 1;
}
message Response {
message Authenticated {
@ -200,21 +190,8 @@ message Response {
int64 count = 3;
}
message PrivacyPassParameters {
bytes token_challenge = 1;
bytes public_key = 2;
uint32 max_batch_size = 3;
uint32 max_age_seconds = 4;
uint32 daily_token_limit = 5;
uint32 issuance_cooldown_seconds = 6;
}
message PrivacyPassTokenResponses {
repeated bytes token_responses = 1;
}
message Ok {
reserved 7, 11;
reserved 7, 11, 22, 23;
oneof Ok {
bool None = 1;
int64 userid = 2;
@ -235,8 +212,6 @@ message Response {
MemoriesList memories_list = 19;
MemoriesUrl memories_url = 20;
MemoriesUsage memories_usage = 21;
PrivacyPassParameters privacy_pass_parameters = 22;
PrivacyPassTokenResponses privacy_pass_token_responses = 23;
}
}

View file

@ -9,9 +9,7 @@ message EncryptedContent {
optional int64 sender_profile_counter = 4;
optional bytes sender_user_discovery_version = 21;
optional bool ask_for_friend_promotions = 25;
/// Announces that this sender accepts sealed-sender envelopes. Peers must
/// keep using the named transport until they have seen this flag.
optional bool sender_accepts_sealed_sender = 28;
reserved 28;
optional MessageUpdate message_update = 5;
optional Media media = 6;

View file

@ -1,90 +0,0 @@
syntax = "proto3";
message EncryptedMessageEnvelope {
// Fresh 32-byte X25519 public key generated for this envelope. The sender
// derives the encryption key from this ephemeral key pair and the recipient's
// public identity key using HKDF-SHA-256 with an empty salt and a 32-byte
// output. HKDF info must be the UTF-8 bytes of
// "twonly-message-envelope-encryption-v1" followed by ephemeral_public_key
// and the recipient's serialized public identity key.
bytes ephemeral_public_key = 1;
// Fresh 24-byte XChaCha20-Poly1305 nonce.
bytes nonce = 2;
// Serialized MessageEnvelope encrypted with XChaCha20-Poly1305. This includes
// the 16-byte Poly1305 authentication tag. The AEAD associated data is exactly
// the UTF-8 bytes of "twonly-message-envelope-encryption-v1".
bytes ciphertext = 3;
}
message MessageEnvelope {
// The serialized MessageEnvelope is encrypted into EncryptedMessageEnvelope
// before it is sent to the server.
// Exact serialized MessageEnvelopePayload bytes. The signature is calculated
// directly over these bytes. Keeping the serialized payload allows verification
// without relying on canonical protobuf serialization.
bytes signed_payload = 1;
// Signature created with the sender's identity key. For a known contact, use
// the pinned public identity key. For an unknown sender, fetch the public key
// from the trusted server, verify this signature, and only then pin the key and
// process the raw message.
bytes signature = 2;
}
message MessageEnvelopePayload {
// Domain-separation and protocol-version marker. The receiver must reject the
// payload unless this is exactly "twonly-message-envelope-v1".
string magic = 1;
// The sender is protected by the encryption of the outer MessageEnvelope.
int64 from_user_id = 2;
// Bind the sender's signature to the intended recipient. This prevents a
// recipient from re-encrypting a valid signed envelope for another user. The
// binding is required because raw_message is not always a Signal ciphertext
// and therefore is not always independently bound to a Signal session. The
// receiver must reject an envelope when this ID does not match its own user ID.
int64 recipient_user_id = 3;
// The mandatory UUIDv4 receipt_id is covered by the envelope signature and is
// used by the receiver to reject replayed messages before processing them.
Message message = 4;
// UTC Unix timestamp in seconds when the envelope was created. The receiver
// must reject envelopes older than 45 days or more than five minutes in the
// future. This field is covered by the envelope signature.
int64 created_at_unix_seconds = 5;
}
message Message {
enum Type {
SENDER_DELIVERY_RECEIPT = 0;
PLAINTEXT_CONTENT = 1;
CIPHERTEXT = 2;
PREKEY_BUNDLE = 3;
TEST_NOTIFICATION = 4;
CIPHERTEXT_V2 = 5;
}
Type type = 1;
string receipt_id = 2;
optional bytes encrypted_content = 3;
optional PlaintextContent plaintext_content = 4;
}
message PlaintextContent {
optional DecryptionErrorMessage decryption_error_message = 1;
optional RetryErrorMessage retry_control_error = 2;
message RetryErrorMessage { }
message DecryptionErrorMessage {
enum Type {
UNKNOWN = 0;
PREKEY_UNKNOWN = 1;
}
Type type = 1;
}
}

View file

@ -0,0 +1,31 @@
syntax = "proto3";
message Message {
enum Type {
SENDER_DELIVERY_RECEIPT = 0;
PLAINTEXT_CONTENT = 1;
CIPHERTEXT = 2;
PREKEY_BUNDLE = 3;
TEST_NOTIFICATION = 4;
CIPHERTEXT_V2 = 5;
}
Type type = 1;
string receipt_id = 2;
optional bytes encrypted_content = 3;
optional PlaintextContent plaintext_content = 4;
}
message PlaintextContent {
optional DecryptionErrorMessage decryption_error_message = 1;
optional RetryErrorMessage retry_control_error = 2;
message RetryErrorMessage { }
message DecryptionErrorMessage {
enum Type {
UNKNOWN = 0;
PREKEY_UNKNOWN = 1;
}
Type type = 1;
}
}

View file

@ -14,7 +14,6 @@ use crate::error::{Result, TwonlyError};
use crate::services::direct_media_upload::DirectMediaUploadService;
use crate::services::groups::GroupService;
use crate::services::mediafiles::MediaFileService;
use crate::services::privacy_pass::PrivacyPassTokens;
use prost::Message as ProstMessage;
use std::future::Future;
use std::pin::Pin;
@ -62,12 +61,6 @@ pub(crate) fn schedule_post_authentication(ctx: &Arc<Context>, in_background: bo
tracing::warn!("failed to replay legacy raw-byte outbox: {error}");
}
// Minting has to happen on an authenticated socket, so the pool is
// topped up while one is available rather than when a message needs it.
if let Err(error) = PrivacyPassTokens::refill_if_needed(&ctx).await {
tracing::warn!("failed to refill the Privacy Pass token pool: {error}");
}
// Runs before the receipt sweep below: without a bundle on the server
// no peer can open a session with this account, so anything they have
// queued for us stays stuck.

View file

@ -1,70 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Anonymous upload endpoint for sealed-sender envelopes.
//!
//! Unlike every other API call this one carries no session and no
//! authentication header: the whole point is that the server must not learn who
//! sent the envelope. The Privacy Pass token in the request is what keeps the
//! endpoint from becoming an open relay.
use crate::api::proto::http_requests::{SealedSenderMessageRequest, SealedSenderMessageResponse};
use crate::bridge::api::RustApi;
use crate::error::{Result, TwonlyError};
use prost::Message as _;
use std::sync::LazyLock;
/// One shared client, so a sealed upload reuses a pooled connection instead of
/// paying for a TLS handshake per message.
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("the sealed-sender HTTP client must be constructible")
});
pub(crate) struct SealedSenderApi;
impl SealedSenderApi {
pub(crate) async fn upload(
recipient_user_id: i64,
sealed_sender_message: Vec<u8>,
privacy_pass_token: Vec<u8>,
wake_receiver: bool,
) -> Result<String> {
let request = SealedSenderMessageRequest {
recipient_user_id,
sealed_sender_message,
privacy_pass_token,
wake_receiver,
};
let response = CLIENT
.post(format!(
"{}sealed-sender/messages",
RustApi::api_base_url("https".into())
))
.header("Content-Type", "application/x-protobuf")
.body(request.encode_to_vec())
.send()
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(TwonlyError::Generic(format!(
"sealed-sender upload returned {status}"
)));
}
let body = response
.bytes()
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
Ok(SealedSenderMessageResponse::decode(body)
.map_err(|error| TwonlyError::Generic(error.to_string()))?
.message_id)
}
}

View file

@ -8,7 +8,6 @@ mod contacts;
mod memories;
mod passwordless;
pub mod prekeys;
mod privacy_pass;
mod purchases;
mod transport;

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
use super::Server;
use crate::api::proto::client_to_server;
use crate::api::proto::server_to_client::response::ok::Ok as ResponseOk;
use crate::api::proto::server_to_client::response::PrivacyPassParameters;
use crate::api::runtime::helpers::decode_ok_value;
use crate::bridge::api::ServerResult;
use crate::context::Context;
use crate::error::Result;
use std::sync::Arc;
impl Server {
/// Loads the current issuance parameters: the daily token challenge, the
/// issuer public key and the limits the client has to stay inside.
pub(crate) async fn get_privacy_pass_parameters(
ctx: &Arc<Context>,
) -> Result<ServerResult<PrivacyPassParameters>> {
let bytes = Self::application(
ctx,
client_to_server::application_data::ApplicationData::GetPrivacyPassParameters(
client_to_server::application_data::GetPrivacyPassParameters {},
),
)
.await?;
decode_ok_value(bytes, |value| match value {
ResponseOk::PrivacyPassParameters(parameters) => Some(parameters),
_ => None,
})
}
/// Exchanges blinded token requests for the server's blinded evaluations.
///
/// This runs over the authenticated socket, so the server counts the tokens
/// against this account's daily quota. The tokens themselves are unlinkable
/// to it once they are finalized.
pub(crate) async fn issue_privacy_pass_tokens(
ctx: &Arc<Context>,
token_requests: Vec<Vec<u8>>,
) -> Result<ServerResult<Vec<Vec<u8>>>> {
let bytes = Self::application(
ctx,
client_to_server::application_data::ApplicationData::IssuePrivacyPassTokens(
client_to_server::application_data::IssuePrivacyPassTokens { token_requests },
),
)
.await?;
decode_ok_value(bytes, |value| match value {
ResponseOk::PrivacyPassTokenResponses(responses) => Some(responses.token_responses),
_ => None,
})
}
}

View file

@ -3,12 +3,11 @@
*
*/
pub(crate) mod log;
mod macros;
use flutter_rust_bridge::DartFnFuture;
use crate::callback_generator;
use crate::error::{Result, TwonlyError};
use crate::{callback_generator, frb_generated::StreamSink};
use std::sync::Arc;
use std::collections::HashMap;
@ -23,9 +22,6 @@ pub(crate) static FLUTTER_CALLBACKS: std::sync::RwLock<Option<HashMap<u32, Flutt
// This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks
callback_generator! {
FlutterCallbacks {
Logging logging {
get_stream_sink: () => StreamSink<String>
},
Api api {
verification_succeeded: (i64) => (),
user_config_changed: (crate::user_config::UserConfig) => ()
@ -47,12 +43,12 @@ pub(crate) fn get_callbacks() -> Result<FlutterCallbacks> {
}
}
// Fallback: if not in a scoped tokio task or if the specific callback_id isn't found,
// we pick the first available callbacks from the map. This gracefully handles
// tracing initialization which happens outside of any scoped task.
if let Some((_, cb)) = map.iter().next() {
// Incoming API events are not always associated with the Flutter call
// that started their work. Preserve the existing fallback for those API
// callbacks; logging no longer depends on this path.
if let Some((_, callbacks)) = map.iter().next() {
tracing::warn!("FlutterCallbacks fallback used: No CURRENT_CALLBACK_ID scope was found, or the ID was missing from the map. Using an arbitrary callback. This may lead to race conditions if multiple isolates are active.");
return Ok(cb.clone());
return Ok(callbacks.clone());
}
Err(TwonlyError::MissingCallbackInitialization)

View file

@ -1,196 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
use crate::frb_generated::StreamSink;
use std::sync::RwLock;
use tracing_subscriber::fmt::MakeWriter;
/// The sink is bound to the native port of the Dart isolate that handed it to
/// us. That isolate can go away while the process (and therefore the tracing
/// subscriber) lives on -- a hot restart, an engine restart, or a background
/// isolate that finished its task. Keeping the sink behind a lock lets a newly
/// initialized isolate take over the log stream instead of writing into a dead
/// port forever.
static DART_SINK: RwLock<Option<StreamSink<String>>> = RwLock::new(None);
pub(crate) fn set_dart_sink(sink: StreamSink<String>) {
if let Ok(mut guard) = DART_SINK.write() {
*guard = Some(sink);
}
}
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(&next) = chars.peek() {
if next == '[' {
// CSI sequence: ESC [ ... [final byte 0x40..=0x7e]
chars.next(); // consume '['
while let Some(&ch) = chars.peek() {
chars.next();
if ('@'..='~').contains(&ch) {
break;
}
}
continue;
} else if next == ']' {
// OSC sequence: ESC ] ... (BEL \x07 or ESC \)
chars.next(); // consume ']'
while let Some(ch) = chars.next() {
if ch == '\x07' {
break;
}
if ch == '\x1b' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
continue;
} else if ('@'..='_').contains(&next) {
// 2-character escape sequence
chars.next();
continue;
}
}
} else if c == '\\' && chars.peek() == Some(&'^') {
let mut clone = chars.clone();
clone.next(); // '^'
if clone.next() == Some('[') {
if clone.peek() == Some(&'[') {
clone.next();
}
let mut valid = false;
while let Some(ch) = clone.next() {
if ('@'..='~').contains(&ch) {
valid = true;
break;
} else if !('0'..='?').contains(&ch) && !(' '..='/').contains(&ch) {
break;
}
}
if valid {
chars.next(); // consume '^'
chars.next(); // consume '['
if chars.peek() == Some(&'[') {
chars.next();
}
while let Some(&ch) = chars.peek() {
chars.next();
if ('@'..='~').contains(&ch) {
break;
}
}
continue;
}
}
} else if c == '^' && chars.peek() == Some(&'[') {
let mut clone = chars.clone();
clone.next(); // '['
if clone.peek() == Some(&'[') {
clone.next();
}
let mut valid = false;
while let Some(ch) = clone.next() {
if ('@'..='~').contains(&ch) {
valid = true;
break;
} else if !('0'..='?').contains(&ch) && !(' '..='/').contains(&ch) {
break;
}
}
if valid {
chars.next(); // consume '['
if chars.peek() == Some(&'[') {
chars.next();
}
while let Some(&ch) = chars.peek() {
chars.next();
if ('@'..='~').contains(&ch) {
break;
}
}
continue;
}
}
out.push(c);
}
out
}
#[derive(Clone, Copy)]
pub(crate) struct DartWriter;
impl std::io::Write for DartWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Ok(msg) = std::str::from_utf8(buf) {
let clean = strip_ansi(msg.trim_end());
let failed = match DART_SINK.read() {
Ok(guard) => match guard.as_ref() {
Some(sink) => sink.add(clean).is_err(),
None => false,
},
Err(_) => false,
};
// The isolate behind the sink is gone. Drop it so we stop paying
// for a send on every log line until a new isolate registers.
if failed {
if let Ok(mut guard) = DART_SINK.write() {
*guard = None;
}
}
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for DartWriter {
type Writer = DartWriter;
fn make_writer(&'a self) -> Self::Writer {
*self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_raw() {
let input =
"\x1b[3mreceipt_id\x1b[0m\x1b[2m=\x1b[0m\"d9891084-0f7c-4f30-958d-d8619df5a91c\"";
assert_eq!(
strip_ansi(input),
"receipt_id=\"d9891084-0f7c-4f30-958d-d8619df5a91c\""
);
}
#[test]
fn test_strip_ansi_escaped_caret() {
let input = r#"\^[[3mreceipt_id\^[[0m\^[[2m=\^[[0m"d9891084-0f7c-4f30-958d-d8619df5a91c" \^[[3muser\^[[0m\^[[2m=\^[[0m2214489137315557376 \^[[3mkind\^[[0m\^[[2m=\^[[0m"FlameSync" Handling incoming message: FlameSync"#;
assert_eq!(
strip_ansi(input),
r#"receipt_id="d9891084-0f7c-4f30-958d-d8619df5a91c" user=2214489137315557376 kind="FlameSync" Handling incoming message: FlameSync"#
);
}
#[test]
fn test_strip_ansi_caret() {
let input = "^[[3mreceipt_id^[[0m^[[2m=^[[0m\"test\"";
assert_eq!(strip_ansi(input), "receipt_id=\"test\"");
}
#[test]
fn test_strip_ansi_plain_text() {
assert_eq!(strip_ansi("hello world 123"), "hello world 123");
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
use crate::error::{Result, TwonlyError};
use flutter_rust_bridge::frb;
#[derive(Clone, Copy, Debug)]
pub enum LogLevel {
Finest,
Fine,
Info,
Warning,
Shout,
}
/// Adds a Dart record to the Rust-owned application log.
///
/// This only performs a short, serialized append and is synchronous so Dart
/// records cannot be reordered by a collection of unawaited futures.
#[frb(sync)]
pub fn write_log(
level: LogLevel,
source: String,
message: String,
in_background: bool,
) -> Result<()> {
crate::log::write_dart_log(level, &source, &message, in_background)
}
pub async fn load_log_file() -> Result<String> {
tokio::task::spawn_blocking(crate::log::load_log_file)
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?
}
pub async fn read_last_log_lines(line_count: u32) -> Result<String> {
tokio::task::spawn_blocking(move || crate::log::read_last_log_lines(line_count as usize))
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?
}
pub async fn clean_log_file() -> Result<()> {
tokio::task::spawn_blocking(crate::log::clean_log_file)
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?
}
/// Truncates `app.log` through its owner instead of unlinking an open file.
pub async fn clear_log_file() -> Result<bool> {
tokio::task::spawn_blocking(crate::log::clear_log_file)
.await
.map_err(|error| TwonlyError::Generic(error.to_string()))?
}

View file

@ -7,6 +7,7 @@
pub mod api;
pub mod callbacks;
pub mod groups;
pub mod logging;
pub mod user_config;
pub mod wrapper;

View file

@ -16,9 +16,11 @@ use crate::secure_storage::SecureStorage;
use crate::signal::engine::RustSignalEngine;
use crate::user_discovery::UserDiscovery;
use crate::utils::Shared;
use libsignal_protocol::IdentityKey;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::{path::PathBuf, sync::Arc};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::sync::{Mutex, Notify, OnceCell, RwLock};
use zeroize::Zeroize;
@ -45,11 +47,6 @@ pub struct Context {
mailbox_drained: Notify,
incoming_generation: AtomicU64,
incoming_committed: Notify,
/// Serializes Privacy Pass minting and holds the earliest time the issuer
/// may be asked again. It belongs to the account, not to the process: two
/// contexts in one process have separate quotas and must not block or back
/// each other off.
pub(crate) privacy_pass_issuance: Mutex<Option<std::time::Instant>>,
/// Set once this connection has confirmed the account has a PQC prekey
/// bundle on the server (or has just published one). Per context rather
/// than process-wide so two accounts in one process check independently.
@ -130,7 +127,6 @@ impl Context {
mailbox_drained: Notify::new(),
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
privacy_pass_issuance: Mutex::new(None),
pqc_bundle_verified: AtomicBool::new(false),
});
ApiRuntime::initialize(&ctx).await?;
@ -192,11 +188,13 @@ impl Context {
std::fs::create_dir_all(&config.database_dir)?;
std::fs::create_dir_all(&config.data_dir)?;
// Ahead of the already-initialized check: the context is a process-wide
// OnceCell, but the calling isolate may be a new one that has to hand
// tracing a live log sink.
let log_dir = PathBuf::from(&config.data_dir).join("log");
init_tracing(&log_dir, runtime_mode == RuntimeMode::Flutter).await;
// Logging is process-wide and owns app.log directly. Initialize it
// before the context check so notification and Flutter runtimes both
// have a sink even when the main context already exists.
init_tracing(
Path::new(&config.data_dir),
runtime_mode != RuntimeMode::Flutter,
);
if GLOBAL_CONTEXT.initialized() {
tracing::info!("twonly already initialized. Ensuring storage directories exist.");
@ -291,7 +289,6 @@ impl Context {
mailbox_drained: Notify::new(),
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
privacy_pass_issuance: Mutex::new(None),
pqc_bundle_verified: AtomicBool::new(false),
});
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
@ -333,7 +330,6 @@ impl Context {
mailbox_drained: Notify::new(),
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
privacy_pass_issuance: Mutex::new(None),
pqc_bundle_verified: AtomicBool::new(false),
});
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
@ -401,23 +397,6 @@ impl Context {
.ok_or_else(|| TwonlyError::Generic("local user ID is missing".into()))
}
pub(crate) async fn get_identity(&self, user_id: i64) -> Result<Option<IdentityKey>> {
let database = self.rust_db.read().await.clone();
let user_id = user_id.to_string();
let identity_key = sqlx::query_scalar!(
r#"SELECT identity_key FROM signal_identities WHERE name = ?"#,
user_id,
)
.fetch_optional(&database.pool)
.await?;
identity_key
.map(|bytes| {
IdentityKey::decode(&bytes).map_err(|error| TwonlyError::Signal(error.to_string()))
})
.transpose()
}
pub(crate) async fn replace_rust_database(
&self,
database: Database,

View file

@ -0,0 +1,3 @@
DROP TABLE privacy_pass_tokens;
ALTER TABLE contacts DROP COLUMN sealed_sender_enabled;

View file

@ -38,7 +38,6 @@ pub struct Contact {
pub ask_for_friend_promotions: Option<i64>,
pub media_send_counter: i64,
pub media_received_counter: i64,
pub sealed_sender_enabled: i64,
}
#[derive(bon::Builder)]
@ -183,27 +182,6 @@ impl Contact {
Ok(())
}
/// Records whether a contact announced that it accepts sealed-sender
/// envelopes. A peer that stops announcing it goes back to named sends.
pub async fn set_sealed_sender_enabled(
t: &mut Transaction<'_, Sqlite>,
user_id: i64,
enabled: bool,
) -> Result<()> {
// Announced on every encrypted content, so the write is skipped unless
// the value actually changes.
sqlx::query!(
r#"UPDATE contacts SET sealed_sender_enabled = ?
WHERE user_id = ? AND sealed_sender_enabled != ?"#,
enabled,
user_id,
enabled,
)
.execute(&mut **t)
.await?;
Ok(())
}
pub async fn is_user_discovery_allowed(
context: &Context,
t: &mut Transaction<'_, Sqlite>,

View file

@ -123,9 +123,6 @@ pub enum TwonlyError {
#[error("Unknown protobuf enum value: {0}")]
UnknownProtobufEnumValue(#[from] prost::UnknownEnumValue),
#[error(transparent)]
SealedSender(#[from] crate::sealed_sender::SealedSenderError),
#[error("{0}")]
HexError(#[from] FromHexError),

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 = 640399573;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 174490644;
// Section: executor
@ -124,6 +124,76 @@ fn wire__crate__bridge__groups__add_new_group_members_impl(
},
)
}
fn wire__crate__bridge__logging__clean_log_file_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: "clean_log_file",
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::logging::clean_log_file().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__logging__clear_log_file_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: "clear_log_file",
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::logging::clear_log_file().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__groups__create_new_group_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -349,10 +419,6 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl(
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_logging_get_stream_sink =
decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer),
);
let api_api_verification_succeeded =
decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(
<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer),
@ -367,7 +433,6 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl(
let output_ok = Result::<_, ()>::Ok({
crate::bridge::callbacks::init_flutter_callbacks(
api_callback_id,
api_logging_get_stream_sink,
api_api_verification_succeeded,
api_api_user_config_changed,
);
@ -488,6 +553,41 @@ fn wire__crate__bridge__groups__leave_group_impl(
},
)
}
fn wire__crate__bridge__logging__load_log_file_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: "load_log_file",
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::logging::load_log_file().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__groups__manage_admin_state_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -531,6 +631,43 @@ fn wire__crate__bridge__groups__manage_admin_state_impl(
},
)
}
fn wire__crate__bridge__logging__read_last_log_lines_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: "read_last_log_lines",
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_line_count = <u32>::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::logging::read_last_log_lines(api_line_count).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__groups__remove_member_from_group_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -4779,40 +4916,6 @@ fn wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(
},
)
}
fn wire__crate__bridge__callbacks__log__strip_ansi_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_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "strip_ansi",
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_input = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, ()>((move || {
let output_ok =
Result::<_, ()>::Ok(crate::bridge::callbacks::log::strip_ansi(&api_input))?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__bridge__groups__update_chat_deletion_time_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -5121,45 +5224,49 @@ fn wire__crate__bridge__user_config__user_config_api_update_impl(
},
)
}
fn wire__crate__bridge__logging__write_log_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "write_log",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
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_level = <crate::bridge::logging::LogLevel>::sse_decode(&mut deserializer);
let api_source = <String>::sse_decode(&mut deserializer);
let api_message = <String>::sse_decode(&mut deserializer);
let api_in_background = <bool>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || {
let output_ok = crate::bridge::logging::write_log(
api_level,
api_source,
api_message,
api_in_background,
)?;
Ok(output_ok)
})(),
)
},
)
}
// Section: related_funcs
fn decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
dart_opaque: flutter_rust_bridge::DartOpaque,
) -> impl Fn() -> flutter_rust_bridge::DartFnFuture<
StreamSink<String, flutter_rust_bridge::for_generated::SseCodec>,
> {
use flutter_rust_bridge::IntoDart;
async fn body(
dart_opaque: flutter_rust_bridge::DartOpaque,
) -> StreamSink<String, flutter_rust_bridge::for_generated::SseCodec> {
let args = vec![];
let message = FLUTTER_RUST_BRIDGE_HANDLER
.dart_fn_invoke(dart_opaque, args)
.await;
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let action = deserializer.cursor.read_u8().unwrap();
let ans = match action {
0 => std::result::Result::Ok(<StreamSink<
String,
flutter_rust_bridge::for_generated::SseCodec,
>>::sse_decode(&mut deserializer)),
1 => std::result::Result::Err(
<flutter_rust_bridge::for_generated::anyhow::Error>::sse_decode(&mut deserializer),
),
_ => unreachable!(),
};
deserializer.end();
let ans = ans.expect("Dart throws exception but Rust side assume it is not failable");
ans
}
move || {
flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body(dart_opaque.clone()))
}
}
fn decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(
dart_opaque: flutter_rust_bridge::DartOpaque,
) -> impl Fn(i64) -> flutter_rust_bridge::DartFnFuture<()> {
@ -5275,14 +5382,6 @@ impl SseDecode for std::collections::HashMap<String, Vec<String>> {
}
}
impl SseDecode for StreamSink<String, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode
for StreamSink<crate::bridge::api::ApiEvent, flutter_rust_bridge::for_generated::SseCodec>
{
@ -5797,6 +5896,21 @@ impl SseDecode for Vec<crate::database::app::SqlValue> {
}
}
impl SseDecode for crate::bridge::logging::LogLevel {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::bridge::logging::LogLevel::Finest,
1 => crate::bridge::logging::LogLevel::Fine,
2 => crate::bridge::logging::LogLevel::Info,
3 => crate::bridge::logging::LogLevel::Warning,
4 => crate::bridge::logging::LogLevel::Shout,
_ => unreachable!("Invalid variant for LogLevel: {}", inner),
};
}
}
impl SseDecode for crate::services::media_upload::MediaSizeReport {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -6255,7 +6369,6 @@ impl SseDecode for crate::user_config::UserConfig {
let mut var_storeMediaFilesInGallery = <bool>::sse_decode(deserializer);
let mut var_autoStoreAllSendUnlimitedMediaFiles = <bool>::sse_decode(deserializer);
let mut var_typingIndicators = <bool>::sse_decode(deserializer);
let mut var_sealedSenderEnabled = <bool>::sse_decode(deserializer);
let mut var_showRestoreFlame = <bool>::sse_decode(deserializer);
let mut var_myBestFriendGroupId = <Option<String>>::sse_decode(deserializer);
let mut var_signalLastSignedPreKeyUpdated =
@ -6321,7 +6434,6 @@ impl SseDecode for crate::user_config::UserConfig {
store_media_files_in_gallery: var_storeMediaFilesInGallery,
auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles,
typing_indicators: var_typingIndicators,
sealed_sender_enabled: var_sealedSenderEnabled,
show_restore_flame: var_showRestoreFlame,
my_best_friend_group_id: var_myBestFriendGroupId,
signal_last_signed_pre_key_updated: var_signalLastSignedPreKeyUpdated,
@ -6380,141 +6492,144 @@ fn pde_ffi_dispatcher_primary_impl(
match func_id {
1 => wire__crate__bridge__groups__add_hidden_contact_impl(port, ptr, rust_vec_len, data_len),
2 => wire__crate__bridge__groups__add_new_group_members_impl(port, ptr, rust_vec_len, data_len),
3 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
51 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
52 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
54 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
56 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__callbacks__log__strip_ansi_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
3 => wire__crate__bridge__logging__clean_log_file_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__bridge__logging__clear_log_file_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
51 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
52 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
54 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
56 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -6527,16 +6642,17 @@ fn pde_ffi_dispatcher_sync_impl(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
19 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
21 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
33 => {
23 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
25 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
37 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
136 => wire__crate__bridge__user_config__user_config_api_clone_impl(
139 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
145 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -6979,6 +7095,30 @@ impl
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::bridge::logging::LogLevel {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::Finest => 0.into_dart(),
Self::Fine => 1.into_dart(),
Self::Info => 2.into_dart(),
Self::Warning => 3.into_dart(),
Self::Shout => 4.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::bridge::logging::LogLevel
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::bridge::logging::LogLevel>
for crate::bridge::logging::LogLevel
{
fn into_into_dart(self) -> crate::bridge::logging::LogLevel {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::services::media_upload::MediaSizeReport {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -7350,7 +7490,6 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
.into_into_dart()
.into_dart(),
self.typing_indicators.into_into_dart().into_dart(),
self.sealed_sender_enabled.into_into_dart().into_dart(),
self.show_restore_flame.into_into_dart().into_dart(),
self.my_best_friend_group_id.into_into_dart().into_dart(),
self.signal_last_signed_pre_key_updated
@ -7466,13 +7605,6 @@ impl SseEncode for std::collections::HashMap<String, Vec<String>> {
}
}
impl SseEncode for StreamSink<String, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode
for StreamSink<crate::bridge::api::ApiEvent, flutter_rust_bridge::for_generated::SseCodec>
{
@ -7882,6 +8014,25 @@ impl SseEncode for Vec<crate::database::app::SqlValue> {
}
}
impl SseEncode for crate::bridge::logging::LogLevel {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(
match self {
crate::bridge::logging::LogLevel::Finest => 0,
crate::bridge::logging::LogLevel::Fine => 1,
crate::bridge::logging::LogLevel::Info => 2,
crate::bridge::logging::LogLevel::Warning => 3,
crate::bridge::logging::LogLevel::Shout => 4,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for crate::services::media_upload::MediaSizeReport {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -8275,7 +8426,6 @@ impl SseEncode for crate::user_config::UserConfig {
<bool>::sse_encode(self.store_media_files_in_gallery, serializer);
<bool>::sse_encode(self.auto_store_all_send_unlimited_media_files, serializer);
<bool>::sse_encode(self.typing_indicators, serializer);
<bool>::sse_encode(self.sealed_sender_enabled, serializer);
<bool>::sse_encode(self.show_restore_flame, serializer);
<Option<String>>::sse_encode(self.my_best_friend_group_id, serializer);
<Option<chrono::DateTime<chrono::Utc>>>::sse_encode(

View file

@ -14,7 +14,6 @@ mod frb_generated;
mod keys;
pub mod log;
mod native;
pub mod sealed_sender;
mod secure_storage;
pub mod services;
pub mod signal;

View file

@ -3,26 +3,112 @@
*
*/
use crate::bridge::callbacks::{
get_callbacks,
log::{set_dart_sink, DartWriter},
};
use crate::bridge::logging::LogLevel;
use crate::error::{Result, TwonlyError};
use std::fmt;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::fs::{File, OpenOptions};
use std::io::{Seek, Write as IoWrite};
use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
};
use tracing::{Event, Subscriber};
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_subscriber::{
fmt::{format::Writer, FmtContext, FormatEvent, FormatFields, FormattedFields, Layer},
fmt::{
format::Writer, FmtContext, FormatEvent, FormatFields, FormattedFields, Layer, MakeWriter,
},
layer::SubscriberExt,
registry::LookupSpan,
util::SubscriberInitExt,
EnvFilter, Registry,
};
type TracingGuards = (Option<WorkerGuard>, WorkerGuard);
static TRACING_GUARDS: OnceLock<Mutex<Option<TracingGuards>>> = OnceLock::new();
static TRACING_INIT: OnceLock<()> = OnceLock::new();
static APP_LOG: OnceLock<AppLog> = OnceLock::new();
static RUST_LOG_IN_BACKGROUND: AtomicBool = AtomicBool::new(false);
struct AppLog {
path: PathBuf,
file: Mutex<File>,
}
impl AppLog {
fn open(path: PathBuf) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
file: Mutex::new(file),
})
}
fn append(&self, bytes: &[u8]) -> std::io::Result<()> {
let mut file = self
.file
.lock()
.map_err(|_| std::io::Error::other("app.log lock was poisoned"))?;
file.write_all(bytes)
}
fn read(&self) -> std::io::Result<String> {
let _file = self
.file
.lock()
.map_err(|_| std::io::Error::other("app.log lock was poisoned"))?;
std::fs::read_to_string(&self.path)
}
fn replace(&self, contents: &str) -> std::io::Result<()> {
let mut file = self
.file
.lock()
.map_err(|_| std::io::Error::other("app.log lock was poisoned"))?;
file.set_len(0)?;
file.seek(std::io::SeekFrom::Start(0))?;
file.write_all(contents.as_bytes())?;
file.seek(std::io::SeekFrom::End(0))?;
Ok(())
}
}
#[derive(Clone, Copy)]
struct AppLogWriter;
struct AppLogBuffer(Vec<u8>);
impl std::io::Write for AppLogBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Drop for AppLogBuffer {
fn drop(&mut self) {
if !self.0.is_empty() {
if let Some(log) = APP_LOG.get() {
if let Err(error) = log.append(&self.0) {
eprintln!("Failed to append to app.log: {error}");
}
}
}
}
}
impl<'a> MakeWriter<'a> for AppLogWriter {
type Writer = AppLogBuffer;
fn make_writer(&'a self) -> Self::Writer {
AppLogBuffer(Vec::new())
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct PlainFields;
@ -47,6 +133,7 @@ impl ShortEventFormatter {
Self { ansi: true }
}
/// The same format without escape codes, for a sink that is not a terminal.
pub const fn plain() -> Self {
Self { ansi: false }
}
@ -136,49 +223,99 @@ where
}
}
pub(crate) async fn init_tracing(logs_dir: &std::path::Path, is_dart_available: bool) {
let _ = std::fs::create_dir_all(logs_dir);
#[derive(Clone, Copy, Debug, Default)]
struct AppLogEventFormatter;
// Runs on *every* init, not just the first one: the subscriber is installed
// once per process, but the isolate it logs into can be replaced (hot
// restart, engine restart, background isolate). Re-registering here points
// the already-installed Dart layer at the isolate that is alive now.
if is_dart_available {
if let Ok(callbacks) = get_callbacks() {
set_dart_sink((callbacks.logging.get_stream_sink)().await);
impl<S, N> FormatEvent<S, N> for AppLogEventFormatter
where
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
N: for<'writer> FormatFields<'writer> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let metadata = event.metadata();
let source = metadata
.file()
.map(Path::new)
.and_then(|path| {
let parent = path.parent()?.file_name()?;
let file = path.file_name()?;
Some(format!(
"{}/{}:{}",
parent.to_string_lossy(),
file.to_string_lossy(),
metadata.line().unwrap_or_default()
))
})
.unwrap_or_else(|| metadata.target().to_owned());
let level = match *metadata.level() {
tracing::Level::TRACE => "FINEST",
tracing::Level::DEBUG => "FINE",
tracing::Level::INFO => "INFO",
tracing::Level::WARN => "WARNING",
tracing::Level::ERROR => "SHOUT",
};
let task = if RUST_LOG_IN_BACKGROUND.load(Ordering::Relaxed) {
'b'
} else {
'f'
};
write!(
writer,
"{} {level} [{task}] [twonly] {source} > ",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.6f")
)?;
if let Some(scope) = ctx.event_scope() {
for span in scope.from_root() {
let extensions = span.extensions();
if let Some(fields) = extensions.get::<FormattedFields<N>>() {
if !fields.is_empty() {
write!(writer, "{fields} ")?;
}
}
}
}
ctx.field_format().format_fields(writer.by_ref(), event)?;
writeln!(writer)
}
}
pub(crate) fn init_tracing(data_dir: &Path, in_background: bool) {
RUST_LOG_IN_BACKGROUND.store(in_background, Ordering::Relaxed);
if APP_LOG.get().is_none() {
match AppLog::open(data_dir.join("app.log")) {
Ok(log) => {
let _ = APP_LOG.set(log);
}
Err(error) => eprintln!("Failed to open app.log: {error}"),
}
}
TRACING_INIT.get_or_init(|| {
let (non_blocking_stdout, _non_blocking_file) = build_writers(logs_dir);
let stdout_layer = Layer::new()
.with_writer(non_blocking_stdout)
.with_writer(std::io::stdout)
.with_ansi(false)
.event_format(ShortEventFormatter::ansi());
// let file_layer = Layer::new()
// .with_writer(non_blocking_file)
// .with_ansi(false)
// .with_target(true);
// Replace stdout with our new DartWriter!
let default_filter = if std::env::var("FLUTTER_TEST").is_ok() {
"info,refinery_core=warn,refinery=warn"
} else {
"debug,refinery_core=warn,refinery=warn"
};
// DartWriter resolves the current sink per write, so the layer is
// installed unconditionally -- it is simply a no-op until an isolate
// registers one. PlainFields separates Dart span fields from stdout fields,
// preventing duplicate fields across layers and keeping Dart fields plain.
let dart_layer = Layer::new()
.with_writer(DartWriter)
let file_layer = Layer::new()
.with_writer(AppLogWriter)
.with_ansi(false)
.fmt_fields(PlainFields)
.event_format(ShortEventFormatter::plain());
.event_format(AppLogEventFormatter);
let _ = Registry::default()
.with(
@ -186,37 +323,103 @@ pub(crate) async fn init_tracing(logs_dir: &std::path::Path, is_dart_available:
.unwrap_or_else(|_| EnvFilter::new(default_filter)),
)
.with(stdout_layer)
.with(dart_layer)
.with(file_layer)
.try_init();
});
}
fn build_writers(logs_dir: &std::path::Path) -> (NonBlocking, NonBlocking) {
let file_appender_res = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("twonly")
.filename_suffix("log")
.build(logs_dir);
let (non_blocking_file, file_guard) = match file_appender_res {
Ok(file_appender) => {
let (nb, guard) = tracing_appender::non_blocking(file_appender);
(nb, Some(guard))
}
Err(e) => {
eprintln!("Failed to create file appender: {}", e);
let (nb, _guard) = tracing_appender::non_blocking(std::io::sink());
(nb, None)
}
};
let (non_blocking_stdout, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
// The stdout guard must outlive this function regardless of whether the
// file appender came up -- dropping it shuts the non-blocking writer thread
// down and silently swallows every log line.
TRACING_GUARDS
.set(Mutex::new(Some((file_guard, stdout_guard))))
.ok();
(non_blocking_stdout, non_blocking_file)
fn app_log() -> Result<&'static AppLog> {
APP_LOG.get().ok_or(TwonlyError::Initialization)
}
pub(crate) fn write_dart_log(
level: LogLevel,
source: &str,
message: &str,
in_background: bool,
) -> Result<()> {
let log = app_log()?;
let level = match level {
LogLevel::Finest => "FINEST",
LogLevel::Fine => "FINE",
LogLevel::Info => "INFO",
LogLevel::Warning => "WARNING",
LogLevel::Shout => "SHOUT",
};
let task = if in_background { 'b' } else { 'f' };
let line = format!(
"{} {level} [{task}] [twonly] {source} > {message}\n",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.6f")
);
log.append(line.as_bytes())?;
Ok(())
}
pub(crate) fn load_log_file() -> Result<String> {
Ok(app_log()?.read()?)
}
pub(crate) fn read_last_log_lines(line_count: usize) -> Result<String> {
let contents = app_log()?.read()?;
let lines: Vec<_> = contents.lines().collect();
let start = lines.len().saturating_sub(line_count);
Ok(lines[start..].join("\n"))
}
pub(crate) fn clean_log_file() -> Result<()> {
let log = app_log()?;
let contents = log.read()?;
let cutoff = chrono::Local::now().naive_local() - chrono::Duration::days(3);
let keep_from = contents.lines().position(|line| {
line.get(..26)
.and_then(|timestamp| {
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S%.f").ok()
})
.is_some_and(|timestamp| timestamp > cutoff)
});
let replacement = match keep_from {
Some(0) => return Ok(()),
Some(index) => {
let mut retained = contents.lines().skip(index).collect::<Vec<_>>().join("\n");
if !retained.is_empty() {
retained.push('\n');
}
retained
}
None => String::new(),
};
log.replace(&replacement)?;
Ok(())
}
pub(crate) fn clear_log_file() -> Result<bool> {
let log = app_log()?;
let had_contents = log
.path
.metadata()
.map(|metadata| metadata.len() > 0)
.unwrap_or(false);
log.replace("")?;
Ok(had_contents)
}
#[cfg(test)]
mod tests {
use super::AppLog;
#[test]
fn app_log_serializes_reads_and_truncation() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("app.log");
let log = AppLog::open(path.clone()).unwrap();
log.append(b"first\n").unwrap();
log.append(b"second\n").unwrap();
assert_eq!(log.read().unwrap(), "first\nsecond\n");
log.replace("retained\n").unwrap();
log.append(b"new\n").unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), "retained\nnew\n");
}
}

View file

@ -1,531 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
use chacha20poly1305::{
aead::{Aead, Payload},
KeyInit, XChaCha20Poly1305, XNonce,
};
use hkdf::Hkdf;
use libsignal_protocol::{IdentityKeyPair, KeyPair, PublicKey};
use prost::Message as ProstMessage;
use rand::{CryptoRng, Rng};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use crate::api::proto::client as proto;
use crate::context::Context;
const PAYLOAD_MAGIC: &str = "twonly-message-envelope-v1";
const ENCRYPTION_CONTEXT: &[u8] = b"twonly-message-envelope-encryption-v1";
const X25519_PUBLIC_KEY_SIZE: usize = 32;
const XCHACHA20_NONCE_SIZE: usize = 24;
const POLY1305_TAG_SIZE: usize = 16;
const ENVELOPE_VALIDITY_SECONDS: i64 = 45 * 24 * 60 * 60;
const MAX_FUTURE_CLOCK_SKEW_SECONDS: i64 = 5 * 60;
pub type Result<T> = std::result::Result<T, SealedSenderError>;
#[derive(Debug, Error)]
pub enum SealedSenderError {
#[error("invalid protobuf: {0}")]
InvalidProtobuf(#[from] prost::DecodeError),
#[error("invalid ephemeral public key")]
InvalidEphemeralPublicKey,
#[error("invalid XChaCha20-Poly1305 nonce")]
InvalidNonce,
#[error("invalid XChaCha20-Poly1305 ciphertext")]
InvalidCiphertext,
#[error("X25519 key agreement failed")]
KeyAgreement,
#[error("sender signature generation failed")]
SignatureGeneration,
#[error("HKDF key derivation failed")]
KeyDerivation,
#[error("XChaCha20-Poly1305 encryption failed")]
EncryptionFailed,
#[error("XChaCha20-Poly1305 authentication failed")]
AuthenticationFailed,
#[error("invalid sealed-sender payload magic")]
InvalidMagic,
#[error("message was addressed to user {actual}, not user {expected}")]
WrongRecipient { expected: i64, actual: i64 },
#[error("sealed-sender payload does not contain a message")]
MissingMessage,
#[error("sealed-sender message has an empty receipt ID")]
MissingReceiptId,
#[error("no identity key is available for sender {0}")]
UnknownSender(i64),
#[error("invalid sender signature")]
InvalidSignature,
#[error("sealed-sender context error: {0}")]
Context(String),
#[error("the local user ID is unavailable")]
MissingLocalUserId,
#[error("the local Signal identity is unavailable")]
MissingLocalIdentity,
#[error("invalid local Signal identity")]
InvalidLocalIdentity,
#[error("system clock is before the Unix epoch")]
InvalidSystemTime,
#[error("sealed-sender envelope has expired")]
ExpiredEnvelope,
#[error("sealed-sender envelope timestamp is too far in the future")]
FutureEnvelope,
}
/// Encrypts and decrypts twonly sealed-sender envelopes.
///
/// The outer encryption authenticates the ciphertext but intentionally does not
/// authenticate the sender. Sender authentication is provided by the XEdDSA
/// identity-key signature inside the encrypted envelope.
pub(crate) struct SealedSender;
impl SealedSender {
pub(crate) fn encrypt<R>(
from_user_id: i64,
recipient_user_id: i64,
message: proto::Message,
sender_identity: &IdentityKeyPair,
recipient_identity_key: &PublicKey,
rng: &mut R,
) -> Result<Vec<u8>>
where
R: Rng + CryptoRng + ?Sized,
{
Self::encrypt_at(
from_user_id,
recipient_user_id,
message,
unix_time_seconds()?,
sender_identity,
recipient_identity_key,
rng,
)
}
fn encrypt_at<R>(
from_user_id: i64,
recipient_user_id: i64,
message: proto::Message,
created_at_unix_seconds: i64,
sender_identity: &IdentityKeyPair,
recipient_identity_key: &PublicKey,
rng: &mut R,
) -> Result<Vec<u8>>
where
R: Rng + CryptoRng + ?Sized,
{
if message.receipt_id.is_empty() {
return Err(SealedSenderError::MissingReceiptId);
}
let payload = proto::MessageEnvelopePayload {
magic: PAYLOAD_MAGIC.to_owned(),
from_user_id,
recipient_user_id,
message: Some(message),
created_at_unix_seconds,
};
let signed_payload = payload.encode_to_vec();
let signature = sender_identity
.private_key()
.calculate_signature(&signed_payload, rng)
.map_err(|_| SealedSenderError::SignatureGeneration)?;
let envelope = proto::MessageEnvelope {
signed_payload,
signature: signature.into_vec(),
}
.encode_to_vec();
let ephemeral_key_pair = KeyPair::generate(rng);
let ephemeral_public_key = ephemeral_key_pair.public_key.public_key_bytes().to_vec();
let shared_secret = ephemeral_key_pair
.private_key
.calculate_agreement(recipient_identity_key)
.map_err(|_| SealedSenderError::KeyAgreement)?;
let key = derive_encryption_key(
&shared_secret,
&ephemeral_public_key,
recipient_identity_key,
)?;
let mut nonce = [0_u8; XCHACHA20_NONCE_SIZE];
rng.fill_bytes(&mut nonce);
let ciphertext = XChaCha20Poly1305::new((&key).into())
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: &envelope,
aad: ENCRYPTION_CONTEXT,
},
)
.map_err(|_| SealedSenderError::EncryptionFailed)?;
Ok(proto::EncryptedMessageEnvelope {
ephemeral_public_key,
nonce: nonce.to_vec(),
ciphertext,
}
.encode_to_vec())
}
/// Decrypts an envelope with the local identity from `context` and verifies
/// the signature using the sender identity stored by Signal.
pub(crate) async fn decrypt(
encrypted_envelope: &[u8],
context: &Context,
) -> Result<proto::MessageEnvelopePayload> {
let (recipient_user_id, recipient_identity) = {
let key_manager = context.key_manager.lock().await;
let recipient_user_id = key_manager
.user_id
.ok_or(SealedSenderError::MissingLocalUserId)?;
let serialized_identity = &key_manager
.signal_identity
.as_ref()
.ok_or(SealedSenderError::MissingLocalIdentity)?
.identity_key_pair_structure;
let recipient_identity = IdentityKeyPair::try_from(serialized_identity.as_slice())
.map_err(|_| SealedSenderError::InvalidLocalIdentity)?;
(recipient_user_id, recipient_identity)
};
let (envelope, payload) =
Self::decrypt_envelope(encrypted_envelope, recipient_user_id, &recipient_identity)?;
let sender_identity = context
.get_identity(payload.from_user_id)
.await
.map_err(context_error)?
.ok_or(SealedSenderError::UnknownSender(payload.from_user_id))?;
verify_sender_and_message(&envelope, &payload, sender_identity.public_key())?;
Ok(payload)
}
fn decrypt_envelope(
encrypted_envelope: &[u8],
recipient_user_id: i64,
recipient_identity: &IdentityKeyPair,
) -> Result<(proto::MessageEnvelope, proto::MessageEnvelopePayload)> {
let encrypted = proto::EncryptedMessageEnvelope::decode(encrypted_envelope)?;
if encrypted.ephemeral_public_key.len() != X25519_PUBLIC_KEY_SIZE {
return Err(SealedSenderError::InvalidEphemeralPublicKey);
}
if encrypted.nonce.len() != XCHACHA20_NONCE_SIZE {
return Err(SealedSenderError::InvalidNonce);
}
if encrypted.ciphertext.len() < POLY1305_TAG_SIZE {
return Err(SealedSenderError::InvalidCiphertext);
}
let ephemeral_public_key =
PublicKey::from_djb_public_key_bytes(&encrypted.ephemeral_public_key)
.map_err(|_| SealedSenderError::InvalidEphemeralPublicKey)?;
let shared_secret = recipient_identity
.private_key()
.calculate_agreement(&ephemeral_public_key)
.map_err(|_| SealedSenderError::KeyAgreement)?;
let key = derive_encryption_key(
&shared_secret,
&encrypted.ephemeral_public_key,
recipient_identity.identity_key().public_key(),
)?;
let plaintext = XChaCha20Poly1305::new((&key).into())
.decrypt(
XNonce::from_slice(&encrypted.nonce),
Payload {
msg: &encrypted.ciphertext,
aad: ENCRYPTION_CONTEXT,
},
)
.map_err(|_| SealedSenderError::AuthenticationFailed)?;
let envelope = proto::MessageEnvelope::decode(plaintext.as_slice())?;
let payload = proto::MessageEnvelopePayload::decode(envelope.signed_payload.as_slice())?;
if payload.magic != PAYLOAD_MAGIC {
return Err(SealedSenderError::InvalidMagic);
}
if payload.recipient_user_id != recipient_user_id {
return Err(SealedSenderError::WrongRecipient {
expected: recipient_user_id,
actual: payload.recipient_user_id,
});
}
Ok((envelope, payload))
}
}
fn verify_sender_and_message(
envelope: &proto::MessageEnvelope,
payload: &proto::MessageEnvelopePayload,
sender_key: &PublicKey,
) -> Result<()> {
if !sender_key.verify_signature(&envelope.signed_payload, &envelope.signature) {
return Err(SealedSenderError::InvalidSignature);
}
let message = payload
.message
.as_ref()
.ok_or(SealedSenderError::MissingMessage)?;
if message.receipt_id.is_empty() {
return Err(SealedSenderError::MissingReceiptId);
}
let now = unix_time_seconds()?;
if payload.created_at_unix_seconds < now.saturating_sub(ENVELOPE_VALIDITY_SECONDS) {
return Err(SealedSenderError::ExpiredEnvelope);
}
if payload.created_at_unix_seconds > now.saturating_add(MAX_FUTURE_CLOCK_SKEW_SECONDS) {
return Err(SealedSenderError::FutureEnvelope);
}
Ok(())
}
fn context_error(error: impl std::fmt::Display) -> SealedSenderError {
SealedSenderError::Context(error.to_string())
}
fn unix_time_seconds() -> Result<i64> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| SealedSenderError::InvalidSystemTime)?
.as_secs();
i64::try_from(seconds).map_err(|_| SealedSenderError::InvalidSystemTime)
}
fn derive_encryption_key(
shared_secret: &[u8],
ephemeral_public_key: &[u8],
recipient_identity_key: &PublicKey,
) -> Result<[u8; 32]> {
let mut info = Vec::with_capacity(
ENCRYPTION_CONTEXT.len()
+ ephemeral_public_key.len()
+ recipient_identity_key.serialize().len(),
);
info.extend_from_slice(ENCRYPTION_CONTEXT);
info.extend_from_slice(ephemeral_public_key);
info.extend_from_slice(&recipient_identity_key.serialize());
let mut key = [0_u8; 32];
Hkdf::<Sha256>::new(None, shared_secret)
.expand(&info, &mut key)
.map_err(|_| SealedSenderError::KeyDerivation)?;
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{rngs::StdRng, SeedableRng};
fn test_message() -> proto::Message {
proto::Message {
r#type: proto::message::Type::PlaintextContent as i32,
receipt_id: "1f58417a-5cd4-4bb6-9f38-2f499d9cfa8e".to_owned(),
..Default::default()
}
}
fn decrypt_with_identity(
encrypted: &[u8],
recipient_user_id: i64,
recipient: &IdentityKeyPair,
sender_key: &PublicKey,
) -> Result<proto::MessageEnvelopePayload> {
let (envelope, payload) =
SealedSender::decrypt_envelope(encrypted, recipient_user_id, recipient)?;
verify_sender_and_message(&envelope, &payload, sender_key)?;
Ok(payload)
}
#[tokio::test]
async fn encrypts_decrypts_and_authenticates_a_sealed_sender_message() {
let mut rng = StdRng::seed_from_u64(7);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let message = test_message();
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 mut key_manager = context.key_manager.lock().await;
key_manager.user_id = Some(42);
key_manager.signal_identity = Some(crate::keys::SignalIdentityKey {
identity_key_pair_structure: recipient.serialize().to_vec(),
registration_id: 1,
});
}
let database = context.rust_db.read().await.clone();
let sender_identity = sender.identity_key().serialize();
sqlx::query!(
r#"
INSERT INTO signal_identities (name, identity_key, timestamp)
VALUES (?, ?, 0)
"#,
"41",
sender_identity.as_ref(),
)
.execute(&database.pool)
.await
.unwrap();
let encrypted = SealedSender::encrypt(
41,
42,
message.clone(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let decrypted = SealedSender::decrypt(&encrypted, &context).await.unwrap();
assert_eq!(decrypted.magic, PAYLOAD_MAGIC);
assert_eq!(decrypted.from_user_id, 41);
assert_eq!(decrypted.recipient_user_id, 42);
assert_eq!(decrypted.message, Some(message));
assert!(decrypted.created_at_unix_seconds > 0);
}
#[test]
fn rejects_tampered_ciphertext() {
let mut rng = StdRng::seed_from_u64(8);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let mut wrapper = proto::EncryptedMessageEnvelope::decode(encrypted.as_slice()).unwrap();
wrapper.ciphertext[0] ^= 1;
let error = decrypt_with_identity(
&wrapper.encode_to_vec(),
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::AuthenticationFailed));
}
#[test]
fn rejects_a_signature_from_another_identity() {
let mut rng = StdRng::seed_from_u64(9);
let sender = IdentityKeyPair::generate(&mut rng);
let impostor = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
impostor.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::InvalidSignature));
}
#[test]
fn rejects_an_envelope_for_another_recipient_before_resolving_sender() {
let mut rng = StdRng::seed_from_u64(10);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = SealedSender::decrypt_envelope(&encrypted, 43, &recipient).unwrap_err();
assert!(matches!(
error,
SealedSenderError::WrongRecipient {
expected: 43,
actual: 42
}
));
}
#[test]
fn rejects_an_expired_envelope() {
let mut rng = StdRng::seed_from_u64(11);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let expired_at = unix_time_seconds().unwrap() - ENVELOPE_VALIDITY_SECONDS - 1;
let encrypted = SealedSender::encrypt_at(
41,
42,
test_message(),
expired_at,
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::ExpiredEnvelope));
}
#[test]
fn rejects_an_envelope_too_far_in_the_future() {
let mut rng = StdRng::seed_from_u64(12);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let future_at = unix_time_seconds().unwrap() + MAX_FUTURE_CLOCK_SKEW_SECONDS + 1;
let encrypted = SealedSender::encrypt_at(
41,
42,
test_message(),
future_at,
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::FutureEnvelope));
}
}

View file

@ -249,6 +249,12 @@ impl MediaUploadService {
if !temp_path.exists() {
return self.retire_media(&media).await;
}
// What the recipient ends up with is this plaintext, so its size is
// recorded while the file is still here: the temporary copy is dropped
// once the upload is scheduled, and the message info still shows it.
if let Err(error) = self.record_plaintext_size(media_id, &temp_path).await {
tracing::warn!(media_id, %error, "could not record the media size");
}
// Auto-storing has to happen before the plaintext is consumed, and only
// for media the recipient could have kept anyway.
@ -571,6 +577,19 @@ impl MediaUploadService {
Ok(())
}
/// The size the user is shown for a media file is the plaintext one, not
/// what the encrypted upload weighs, and it is kept even after the file
/// itself is gone.
async fn record_plaintext_size(&self, media_id: &str, path: &Path) -> Result<()> {
let size = std::fs::metadata(path)?.len() as i64;
self.update_media(
"UPDATE media_files SET size_in_bytes = ? WHERE media_id = ?",
size,
media_id,
)
.await
}
/// Called by Flutter after a plugin step rewrote a media file on disk, so
/// the derived state Rust owns is recomputed from what was actually written.
pub async fn media_step_finished(&self, media_id: &str, kind: &str) -> Result<()> {

View file

@ -263,14 +263,17 @@ impl MediaFileService {
std::fs::write(&temp_path, &bytes)?;
let hash = Sha256::digest(&bytes).to_vec();
let size = bytes.len() as i64;
// Keep the file update and state transition ordered: ready is only
// visible after the plaintext has been written successfully.
let database = self.ctx.app_db.read().await.clone();
sqlx::query!(
r#"UPDATE media_files SET download_state = 'ready', stored_file_hash = ?
r#"UPDATE media_files SET download_state = 'ready', stored_file_hash = ?,
size_in_bytes = ?
WHERE media_id = ?"#,
hash,
size,
media.media_id,
)
.execute(&database.pool)

View file

@ -13,5 +13,3 @@ pub mod media_upload;
pub mod mediafiles;
pub mod messages;
pub mod notifications;
pub mod privacy_pass;
pub mod sealed_sender;

View file

@ -95,7 +95,7 @@ pub struct NotificationPresentation {
pub fn fallback_presentation(locale: &str) -> NotificationPresentation {
NotificationPresentation {
title: translation(locale, "notificationCategoryMessageTitle").to_owned(),
body: translation(locale, "notificationCategoryMessageDesc").to_owned(),
body: translation(locale, "notificationConnectionFallback").to_owned(),
}
}
@ -692,6 +692,18 @@ mod tests {
assert_eq!(localized_body("fr-FR", &row), "wants to connect with you.");
}
#[test]
fn localizes_connection_fallback_notification() {
assert_eq!(
fallback_presentation("en-US").body,
"You may have new messages."
);
assert_eq!(
fallback_presentation("de-DE").body,
"Du könntest neue Nachrichten haben."
);
}
#[test]
fn interpolates_group_and_reaction_placeholders() {
let mut group = pending_row("text");

View file

@ -1,364 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Client side of the Privacy Pass token pool that rate-limits sealed-sender
//! uploads.
//!
//! Tokens are minted over the authenticated WebSocket, where the server counts
//! them against this account's daily quota, and spent later on the anonymous
//! HTTP upload endpoint. Because the issued token is unblinded locally, the
//! server cannot link a redeemed token back to the account it was issued to,
//! which is what lets the upload stay unauthenticated without becoming an open
//! relay.
use crate::api::Server;
use crate::bridge::api::ServerResult;
use crate::context::Context;
use crate::error::{Result, TwonlyError};
use crate::user_config::UserConfig;
use p384::NistP384;
use privacypass::auth::authenticate::TokenChallenge;
use privacypass::common::private::deserialize_public_key;
use privacypass::private_tokens::{TokenRequest, TokenResponse, TokenState};
use privacypass::Serialize as PrivacyPassSerialize;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Refill once the pool can no longer cover a short burst of messages.
///
/// The issuer only lets a session mint every few seconds, so this has to leave
/// room for a whole cooldown's worth of sends: a threshold that trips only once
/// the pool is nearly empty guarantees a stretch of named messages while the
/// refill waits its turn.
const REFILL_THRESHOLD: i64 = 10;
/// Never ask for more than this, however large the server's batch limit is.
/// Every token in a batch costs a blind and an unblind, both P-384 scalar
/// multiplications, so the batch size is what bounds the CPU spike a refill
/// puts on the device.
const MAX_TOKENS_PER_REFILL: usize = 20;
/// Stop using a token slightly before the server would reject it, so a message
/// in flight around midnight UTC is not refused.
const EXPIRY_SAFETY_MARGIN_SECONDS: i64 = 5 * 60;
/// Back off this long when the issuer refuses for a reason we cannot classify.
const UNKNOWN_REFUSAL_BACKOFF: Duration = Duration::from_secs(15 * 60);
/// Assumed issuance cooldown while the server's own value is unknown, which is
/// the case only when the parameter request itself failed.
const ASSUMED_ISSUANCE_COOLDOWN: Duration = Duration::from_secs(5);
/// A refill waits out a backoff no longer than this and gives up on anything
/// beyond it. The issuance cooldown is seconds long and worth waiting for; an
/// exhausted daily quota lasts until midnight and is not.
const MAX_BACKOFF_WAIT: Duration = Duration::from_secs(30);
pub(crate) struct PrivacyPassTokens;
impl PrivacyPassTokens {
/// Removes one unexpired token from the local pool.
///
/// A token is spent by handing it to the server, so it is deleted before it
/// is used: replaying it would be rejected anyway, and keeping it would
/// stall every later message behind the same dead token.
///
/// Minting never happens here. It needs two server round trips, and putting
/// those on the send path would delay the very message that emptied the
/// pool; an empty pool simply means this message goes out named while the
/// refill runs behind it.
pub(crate) async fn take(ctx: &Arc<Context>) -> Result<Option<Vec<u8>>> {
let token = Self::pop(ctx).await?;
Self::spawn_refill(ctx);
Ok(token)
}
/// Tops the pool up when it is running low. Safe to call on every
/// connection: it is a no-op while enough tokens are left.
pub(crate) async fn refill_if_needed(ctx: &Arc<Context>) -> Result<()> {
// Tokens are only ever spent on sealed-sender uploads. Minting them for
// an account that has the feature off would burn its daily quota on
// tokens it can never use.
if !UserConfig::load_from(ctx)?.is_some_and(|config| config.sealed_sender_enabled) {
return Ok(());
}
if Self::count(ctx).await? >= REFILL_THRESHOLD {
return Ok(());
}
Self::refill(ctx).await
}
pub(crate) fn spawn_refill(ctx: &Arc<Context>) {
let ctx = ctx.clone();
tokio::spawn(async move {
if let Err(error) = Self::refill_if_needed(&ctx).await {
tracing::warn!("Privacy Pass refill failed: {error}");
}
});
}
async fn count(ctx: &Arc<Context>) -> Result<i64> {
let database = ctx.app_db.read().await.clone();
let now = now_seconds();
Ok(sqlx::query_scalar!(
"SELECT COUNT(*) FROM privacy_pass_tokens WHERE expires_at > ?",
now
)
.fetch_one(&database.pool)
.await?)
}
async fn pop(ctx: &Arc<Context>) -> Result<Option<Vec<u8>>> {
let database = ctx.app_db.read().await.clone();
let now = now_seconds();
let mut transaction = database.pool.begin().await?;
sqlx::query!("DELETE FROM privacy_pass_tokens WHERE expires_at <= ?", now)
.execute(&mut *transaction)
.await?;
let token = sqlx::query_scalar!(
r#"DELETE FROM privacy_pass_tokens
WHERE token = (
SELECT token FROM privacy_pass_tokens
WHERE expires_at > ?
ORDER BY expires_at ASC
LIMIT 1
)
RETURNING token"#,
now
)
.fetch_optional(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(token)
}
async fn refill(ctx: &Arc<Context>) -> Result<()> {
// A refill already in flight will fill the pool for everyone waiting on
// it, so a second one would only spend quota twice.
let Ok(mut issuance) = ctx.privacy_pass_issuance.try_lock() else {
return Ok(());
};
// A backoff short enough to be the issuer's cooldown is waited out
// rather than skipped: returning here would leave the pool empty with
// nothing scheduled to fill it, so every message until the next send
// would go named. Longer backoffs are not worth holding a task for.
if let Some(next) = *issuance {
let wait = next.saturating_duration_since(Instant::now());
if wait > MAX_BACKOFF_WAIT {
return Ok(());
}
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
}
// Another caller may have filled the pool while this one waited.
if Self::count(ctx).await? >= REFILL_THRESHOLD {
return Ok(());
}
let parameters = match Server::get_privacy_pass_parameters(ctx).await? {
ServerResult::Ok(parameters) => parameters,
ServerResult::ErrorCode(code) => {
*issuance = Some(Instant::now() + backoff_for(code, ASSUMED_ISSUANCE_COOLDOWN));
return Err(TwonlyError::Generic(format!(
"server rejected the Privacy Pass parameter request with code {code}"
)));
}
};
let challenge = TokenChallenge::deserialize(parameters.token_challenge.as_slice())
.map_err(|error| {
TwonlyError::Generic(format!("invalid Privacy Pass challenge: {error}"))
})?;
let public_key =
deserialize_public_key::<NistP384>(&parameters.public_key).map_err(|error| {
TwonlyError::Generic(format!("invalid Privacy Pass public key: {error}"))
})?;
let batch_size = (parameters.max_batch_size as usize).min(MAX_TOKENS_PER_REFILL);
if batch_size == 0 {
return Err(TwonlyError::Generic(
"server does not issue any Privacy Pass tokens".into(),
));
}
// Blinding a batch is a run of P-384 scalar multiplications. On the
// async runtime it would stall the socket tasks sharing those threads,
// which is felt as dropped connections rather than as slow minting.
let (requests, states) = tokio::task::spawn_blocking(move || {
let mut requests = Vec::with_capacity(batch_size);
let mut states: Vec<TokenState<NistP384>> = Vec::with_capacity(batch_size);
for _ in 0..batch_size {
let (request, state) = TokenRequest::<NistP384>::new(public_key, &challenge)
.map_err(|error| {
TwonlyError::Generic(format!(
"could not blind a Privacy Pass token: {error}"
))
})?;
requests.push(request.tls_serialize_detached().map_err(|error| {
TwonlyError::Generic(format!("could not serialize a token request: {error}"))
})?);
states.push(state);
}
Ok::<_, TwonlyError>((requests, states))
})
.await
.map_err(|error| TwonlyError::Generic(format!("token blinding panicked: {error}")))??;
let cooldown = Duration::from_secs(u64::from(parameters.issuance_cooldown_seconds));
let responses = match Server::issue_privacy_pass_tokens(ctx, requests).await? {
ServerResult::Ok(responses) => {
// The issuer refuses a session that mints again inside its
// cooldown, and that refusal is indistinguishable from real
// trouble. Pacing the next refill here keeps the client from
// provoking one.
*issuance = Some(Instant::now() + cooldown);
responses
}
ServerResult::ErrorCode(code) => {
*issuance = Some(Instant::now() + backoff_for(code, cooldown));
return Err(TwonlyError::Generic(format!(
"server rejected the Privacy Pass issuance with code {code}"
)));
}
};
if responses.len() != states.len() {
return Err(TwonlyError::Generic(
"server returned the wrong number of Privacy Pass responses".into(),
));
}
// Unblinding is the same kind of curve work, so it is offloaded too.
let tokens = tokio::task::spawn_blocking(move || {
responses
.into_iter()
.zip(states.iter())
.map(|(serialized, state)| {
let response = TokenResponse::<NistP384>::try_from_bytes(&serialized).map_err(
|error| {
TwonlyError::Generic(format!("invalid Privacy Pass response: {error}"))
},
)?;
response
.issue_token(state)
.map_err(|error| {
TwonlyError::Generic(format!(
"could not finalize a Privacy Pass token: {error}"
))
})?
.tls_serialize_detached()
.map_err(|error| {
TwonlyError::Generic(format!(
"could not serialize a Privacy Pass token: {error}"
))
})
})
.collect::<Result<Vec<Vec<u8>>>>()
})
.await
.map_err(|error| TwonlyError::Generic(format!("token finalization panicked: {error}")))??;
let expires_at = expiry_for_todays_challenge(i64::from(parameters.max_age_seconds));
let database = ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
for token in tokens {
sqlx::query!(
r#"INSERT INTO privacy_pass_tokens(token, expires_at) VALUES (?, ?)
ON CONFLICT(token) DO NOTHING"#,
token,
expires_at,
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
}
fn now_seconds() -> i64 {
chrono::Utc::now().timestamp()
}
/// How long to leave the issuer alone after it refused to mint.
///
/// The two refusals the server sends routinely are minutes apart in cost, and
/// treating them alike is what took sealed sender down for a quarter of an hour
/// over a burst the client only had to pace.
fn backoff_for(code: i32, cooldown: Duration) -> Duration {
use crate::api::proto::error::ErrorCode;
if code == ErrorCode::TooManyRequests as i32 {
// The per-session issuance cooldown. It clears in seconds.
cooldown
} else if code == ErrorCode::PrivacyPassQuotaExhausted as i32 {
// The daily counter is keyed on the server's calendar date, so nothing
// this account does before midnight earns another token.
duration_until_next_utc_day()
} else {
UNKNOWN_REFUSAL_BACKOFF
}
}
/// Time left in the current UTC day, which is when the issuer's daily counter
/// rolls over. Never zero, so a refusal always costs at least one wait.
fn duration_until_next_utc_day() -> Duration {
const SECONDS_PER_DAY: i64 = 24 * 60 * 60;
let now = now_seconds();
let remaining = SECONDS_PER_DAY - now.rem_euclid(SECONDS_PER_DAY);
Duration::from_secs(remaining.max(1) as u64)
}
/// The server derives its challenge from the current UTC day and accepts a
/// token for `max_age_seconds` counted from the start of that day, not from the
/// moment it was issued.
fn expiry_for_todays_challenge(max_age_seconds: i64) -> i64 {
const SECONDS_PER_DAY: i64 = 24 * 60 * 60;
let start_of_day = now_seconds().div_euclid(SECONDS_PER_DAY) * SECONDS_PER_DAY;
start_of_day + max_age_seconds - EXPIRY_SAFETY_MARGIN_SECONDS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_expire_with_the_day_they_were_issued_for() {
const SECONDS_PER_DAY: i64 = 24 * 60 * 60;
let expiry = expiry_for_todays_challenge(7 * SECONDS_PER_DAY);
let start_of_day = now_seconds().div_euclid(SECONDS_PER_DAY) * SECONDS_PER_DAY;
assert_eq!(
expiry,
start_of_day + 7 * SECONDS_PER_DAY - EXPIRY_SAFETY_MARGIN_SECONDS
);
assert!(expiry > now_seconds());
}
#[test]
fn the_issuance_cooldown_does_not_cost_a_quarter_of_an_hour() {
use crate::api::proto::error::ErrorCode;
let cooldown = Duration::from_secs(5);
assert_eq!(
backoff_for(ErrorCode::TooManyRequests as i32, cooldown),
cooldown
);
assert_eq!(
backoff_for(ErrorCode::InternalError as i32, cooldown),
UNKNOWN_REFUSAL_BACKOFF
);
// Nothing earns a token before the daily counter rolls over, so this
// one must not come back after a mere cooldown.
assert!(
backoff_for(ErrorCode::PrivacyPassQuotaExhausted as i32, cooldown)
== duration_until_next_utc_day()
);
}
#[test]
fn the_daily_quota_backoff_ends_within_the_day() {
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
let remaining = duration_until_next_utc_day();
assert!(!remaining.is_zero());
assert!(remaining <= Duration::from_secs(SECONDS_PER_DAY));
}
}

View file

@ -1,216 +0,0 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Chooses between the sealed and the named transport for an outgoing message.
//!
//! A sealed envelope is uploaded anonymously, so the server never learns who
//! sent it. That only works when both sides agree: the recipient has to
//! understand the envelope, and this account has to have the feature enabled.
//! Everything else — an unknown identity key, an empty token pool, a failing
//! upload — falls back to the named transport rather than dropping the message.
use crate::api::messages::incoming::messages::PreparedQueuedReceipt;
use crate::api::sealed_sender::SealedSenderApi;
use crate::context::Context;
use crate::error::{Result, TwonlyError};
use crate::sealed_sender::SealedSender;
use crate::services::privacy_pass::PrivacyPassTokens;
use crate::user_config::UserConfig;
use libsignal_protocol::IdentityKeyPair;
use rand::SeedableRng;
use std::sync::Arc;
pub(crate) struct SealedSenderService;
impl SealedSenderService {
/// Whether messages to this contact are allowed to travel sealed.
///
/// The contact's flag is only set once they have announced support in an
/// encrypted content of their own, so a peer that does not understand the
/// envelope never receives one.
pub(crate) async fn is_enabled_for(ctx: &Arc<Context>, contact_id: i64) -> Result<bool> {
// The contact's flag is checked first on purpose. This runs for every
// queued receipt, and reading the user configuration means a file read
// and a JSON parse under a process-wide lock, which is far more
// expensive than an indexed lookup on the primary key.
let database = ctx.app_db.read().await.clone();
let contact_accepts = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND sealed_sender_enabled = 1)",
contact_id,
)
.fetch_one(&database.pool)
.await?
!= 0;
if !contact_accepts {
return Ok(false);
}
Ok(UserConfig::load_from(ctx)?.is_some_and(|config| config.sealed_sender_enabled))
}
/// Tries to deliver a prepared receipt as a sealed envelope.
///
/// Returns `false` when the message has to go out over the named transport
/// instead. The caller may then send it normally: a receiver that ends up
/// seeing both copies discards the second one by receipt ID.
pub(crate) async fn try_send(
ctx: &Arc<Context>,
receipt: &PreparedQueuedReceipt,
) -> Result<bool> {
if !Self::is_enabled_for(ctx, receipt.contact_id).await? {
return Ok(false);
}
let Some(recipient_identity) = ctx.get_identity(receipt.contact_id).await? else {
tracing::info!(
contact_id = receipt.contact_id,
"no pinned identity key yet; sending this message named"
);
return Ok(false);
};
let (from_user_id, sender_identity) = {
let key_manager = ctx.key_manager.lock().await;
let Some(from_user_id) = key_manager.user_id else {
return Ok(false);
};
let Some(identity) = key_manager.signal_identity.as_ref() else {
return Ok(false);
};
let identity =
IdentityKeyPair::try_from(identity.identity_key_pair_structure.as_slice())
.map_err(|error| TwonlyError::Signal(error.to_string()))?;
(from_user_id, identity)
};
// An empty pool, or an issuer that refuses to mint, must never hold a
// message back: it just goes out named instead.
let token = match PrivacyPassTokens::take(ctx).await {
Ok(Some(token)) => token,
Ok(None) => {
tracing::info!("no Privacy Pass token available; sending this message named");
return Ok(false);
}
Err(error) => {
tracing::warn!("could not obtain a Privacy Pass token: {error}");
return Ok(false);
}
};
let envelope = {
let mut rng = rand::rngs::StdRng::from_os_rng();
SealedSender::encrypt(
from_user_id,
receipt.contact_id,
receipt.message.clone(),
&sender_identity,
recipient_identity.public_key(),
&mut rng,
)?
};
match SealedSenderApi::upload(receipt.contact_id, envelope, token, receipt.wake_receiver)
.await
{
Ok(message_id) => {
tracing::info!(
receipt_id = receipt.receipt_id,
message_id,
"sent message via sealed sender"
);
Ok(true)
}
Err(error) => {
tracing::warn!("sealed-sender upload failed, falling back to named send: {error}");
Ok(false)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::app::tables::Contact;
async fn context(
sealed_sender_enabled: bool,
) -> anyhow::Result<(tempfile::TempDir, Arc<Context>)> {
let temp = tempfile::tempdir()?;
let data_dir = temp.path().join("data");
std::fs::create_dir_all(data_dir.join("keyvalue"))?;
let config = UserConfig {
sealed_sender_enabled,
..Default::default()
};
std::fs::write(
data_dir.join("keyvalue/user.json"),
serde_json::to_vec(&config)?,
)?;
let context = Context::init_for_testing(temp.path().join("database"), data_dir).await?;
Ok((temp, context))
}
async fn insert_contact(ctx: &Arc<Context>, user_id: i64) -> anyhow::Result<()> {
let database = ctx.app_db.read().await.clone();
sqlx::query!(
"INSERT INTO contacts(user_id, username, accepted) VALUES (?, ?, 1)",
user_id,
"peer",
)
.execute(&database.pool)
.await?;
Ok(())
}
async fn announce(ctx: &Arc<Context>, user_id: i64, enabled: bool) -> anyhow::Result<()> {
let database = ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
Contact::set_sealed_sender_enabled(&mut transaction, user_id, enabled).await?;
transaction.commit().await?;
Ok(())
}
#[tokio::test]
async fn a_contact_that_never_announced_support_keeps_the_named_transport() -> anyhow::Result<()>
{
let (_temp, ctx) = context(true).await?;
insert_contact(&ctx, 21).await?;
assert!(!SealedSenderService::is_enabled_for(&ctx, 21).await?);
Ok(())
}
#[tokio::test]
async fn both_sides_have_to_enable_the_feature() -> anyhow::Result<()> {
let (_temp, ctx) = context(true).await?;
insert_contact(&ctx, 21).await?;
announce(&ctx, 21, true).await?;
assert!(SealedSenderService::is_enabled_for(&ctx, 21).await?);
// The contact withdrawing its announcement is enough to stop sealing.
announce(&ctx, 21, false).await?;
assert!(!SealedSenderService::is_enabled_for(&ctx, 21).await?);
Ok(())
}
#[tokio::test]
async fn the_local_setting_disables_sealing_for_every_contact() -> anyhow::Result<()> {
let (_temp, ctx) = context(false).await?;
insert_contact(&ctx, 21).await?;
announce(&ctx, 21, true).await?;
assert!(!SealedSenderService::is_enabled_for(&ctx, 21).await?);
Ok(())
}
#[tokio::test]
async fn an_unknown_contact_is_never_sealed() -> anyhow::Result<()> {
let (_temp, ctx) = context(true).await?;
assert!(!SealedSenderService::is_enabled_for(&ctx, 404).await?);
Ok(())
}
}

View file

@ -248,12 +248,6 @@ pub struct UserConfig {
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub typing_indicators: bool,
/// Announces to contacts that this account accepts sealed-sender envelopes,
/// and lets this account send them. Both sides have to have it on before
/// anything travels sealed.
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub sealed_sender_enabled: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub show_restore_flame: bool,

View file

@ -10,8 +10,6 @@ mod media;
mod notifications;
#[path = "api/recovery.rs"]
mod recovery;
#[path = "api/sealed_sender.rs"]
mod sealed_sender;
#[path = "api/server_api.rs"]
mod server_api;
#[path = "api/session_recovery.rs"]

View file

@ -1,109 +0,0 @@
use crate::tester::{init_tracing, Tester};
use rust_lib_twonly::bridge::api::ApiConnectionState;
use rust_lib_twonly::database::app::tables::Group;
use rust_lib_twonly::services::contacts::ContactService;
use rust_lib_twonly::services::messages::MessageService;
async fn ready_tester() -> anyhow::Result<Tester> {
let mut tester = Tester::new().await?;
tester.set_sealed_sender_enabled(true)?;
tester.wait_until(ApiConnectionState::Connected).await?;
tester.register_and_authenticate().await?;
tester.wait_until(ApiConnectionState::Authenticated).await?;
Ok(tester)
}
/// Exercises the whole sealed-sender path against the running dev server:
/// Privacy Pass issuance, the capability announcement that gates it, the
/// anonymous upload, and delivery back to the recipient.
#[tokio::test]
async fn test_sealed_sender_end_to_end() -> anyhow::Result<()> {
init_tracing();
let tester_a = ready_tester().await?;
let tester_b = ready_tester().await?;
tracing::info!(
sender = tester_a.user_id,
recipient = tester_b.user_id,
"Testers are ready"
);
let group_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id);
// Tokens are minted right after authentication, so a message never has to
// wait for a round trip before it can be sealed.
tester_a.wait_for_privacy_pass_tokens().await?;
ContactService::new(&tester_a.context)
.request_by_username(tester_b.username.clone(), true)
.await?;
tester_b
.wait_for_contact_state(tester_a.user_id, false, true)
.await?;
ContactService::new(&tester_b.context)
.accept_request(tester_a.user_id, true)
.await?;
tester_a
.wait_for_contact_state(tester_b.user_id, true, false)
.await?;
// The contact request and its acceptance each carry the announcement, so
// by now both sides know the other accepts sealed envelopes.
tester_a
.wait_for_contact_sealed_sender(tester_b.user_id, true)
.await?;
tester_b
.wait_for_contact_sealed_sender(tester_a.user_id, true)
.await?;
// A sealed message arrives like any other, but the server never sees who
// sent it.
let sealed_id = MessageService::new(&tester_a.context)
.insert_and_send_text(group_id.clone(), "Sealed hello".into(), None)
.await?;
tester_b
.wait_for_text_message(&sealed_id, tester_a.user_id, "Sealed hello")
.await?;
tester_a.wait_for_message_ack_by_server(&sealed_id).await?;
assert!(
tester_a
.was_sent_sealed(&sealed_id, tester_b.user_id)
.await?,
"message was not delivered over the sealed transport"
);
// Turning the feature off locally has to fall back to the named transport
// without losing the message.
tester_a.set_sealed_sender_enabled(false)?;
let named_id = MessageService::new(&tester_a.context)
.insert_and_send_text(group_id.clone(), "Named hello".into(), None)
.await?;
tester_b
.wait_for_text_message(&named_id, tester_a.user_id, "Named hello")
.await?;
tester_a.wait_for_message_ack_by_server(&named_id).await?;
assert!(
!tester_a
.was_sent_sealed(&named_id, tester_b.user_id)
.await?,
"message was sealed even though the local setting is off"
);
// Withdrawing the announcement has to stop the peer from sealing, too.
tester_b
.wait_for_contact_sealed_sender(tester_a.user_id, false)
.await?;
tester_a.set_sealed_sender_enabled(true)?;
let back_id = MessageService::new(&tester_b.context)
.insert_and_send_text(group_id.clone(), "Back to you".into(), None)
.await?;
tester_a
.wait_for_text_message(&back_id, tester_b.user_id, "Back to you")
.await?;
assert!(
!tester_b.was_sent_sealed(&back_id, tester_a.user_id).await?,
"peer sealed a message to a contact that withdrew its announcement"
);
Ok(())
}

View file

@ -412,8 +412,6 @@ impl Tester {
std::fs::create_dir_all(data_dir.join("keyvalue"))?;
let config = rust_lib_twonly::user_config::UserConfig {
// Must be at least the server's `sealed_sender_min_app_version`, or
// the server refuses to deliver sealed-sender payloads to a tester.
app_version: 119,
device_id: 1,
can_use_login_token_for_auth: true,
@ -421,11 +419,6 @@ impl Tester {
user_discovery_threshold: 3,
user_discovery_share_promotion: true,
typing_indicators: true,
// Off here, on in the sealed-sender test. Minting a Privacy Pass
// batch is a run of P-384 scalar multiplications, and paying that
// in every unrelated test would slow the debug-built suite down
// for coverage the sealed-sender test already provides.
sealed_sender_enabled: false,
..Default::default()
};
std::fs::write(
@ -462,81 +455,6 @@ impl Tester {
})
}
pub fn set_sealed_sender_enabled(&self, enabled: bool) -> anyhow::Result<()> {
let path = self
._temp_dir
.path()
.join("data")
.join("keyvalue")
.join("user.json");
let content = std::fs::read_to_string(&path)?;
let mut config: rust_lib_twonly::user_config::UserConfig = serde_json::from_str(&content)?;
config.sealed_sender_enabled = enabled;
std::fs::write(&path, serde_json::to_string(&config)?)?;
Ok(())
}
/// Waits until the contact has announced (or withdrawn) sealed-sender
/// support. The announcement rides along with any encrypted content, so it
/// only lands once the peer has actually sent something.
pub async fn wait_for_contact_sealed_sender(
&self,
user_id: i64,
expected: bool,
) -> anyhow::Result<()> {
for _ in 0..100 {
let database = self.context.app_db.read().await.clone();
let enabled = sqlx::query_scalar!(
"SELECT sealed_sender_enabled FROM contacts WHERE user_id = ?",
user_id
)
.fetch_optional(&database.pool)
.await?;
if enabled == Some(i64::from(expected)) {
return Ok(());
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow::anyhow!(
"contact {user_id} did not reach sealed_sender_enabled = {expected}"
))
}
/// Whether this message's copy for `contact_id` left as a sealed envelope.
pub async fn was_sent_sealed(&self, message_id: &str, contact_id: i64) -> anyhow::Result<bool> {
let database = self.context.app_db.read().await.clone();
Ok(sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM message_actions
WHERE message_id = ? AND contact_id = ? AND type = 'sealedSenderAt')"#,
message_id,
contact_id,
)
.fetch_one(&database.pool)
.await?
!= 0)
}
/// Waits for the token pool to fill. Minting is deliberately kept off the
/// send path, so it lands shortly after authentication rather than during
/// it.
///
/// The wait is generous because blinding and unblinding a batch are P-384
/// scalar multiplications, and this suite is built without optimizations,
/// where each one costs orders of magnitude more than in a release build.
pub async fn wait_for_privacy_pass_tokens(&self) -> anyhow::Result<()> {
for _ in 0..600 {
let database = self.context.app_db.read().await.clone();
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM privacy_pass_tokens")
.fetch_one(&database.pool)
.await?;
if count > 0 {
return Ok(());
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow::anyhow!("the Privacy Pass token pool stayed empty"))
}
pub fn update_username(&mut self, new_username: String) -> anyhow::Result<()> {
self.username = new_username.clone();

View file

@ -1,48 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:logging/logging.dart';
import 'package:twonly/src/callbacks/logging.callbacks.dart';
void main() {
group('LoggingCallbacks ANSI stripping', () {
test('removes raw ANSI color escape sequences', () {
LogRecord? captured;
final sub = Logger.root.onRecord.listen((record) {
captured = record;
});
const rawAnsiLog =
'12:34:56 INFO dir/test.rs:42 \x1b[3mreceipt_id\x1b[0m\x1b[2m=\x1b[0m"d9891084-0f7c-4f30-958d-d8619df5a91c" Handling incoming message: FlameSync';
LoggingCallbacks.handleRustLog(rawAnsiLog);
expect(captured, isNotNull);
expect(captured!.loggerName, 'dir/test.rs:42');
expect(
captured!.message,
'receipt_id="d9891084-0f7c-4f30-958d-d8619df5a91c" Handling incoming message: FlameSync',
);
sub.cancel();
});
test('removes escaped caret ANSI notation', () {
LogRecord? captured;
final sub = Logger.root.onRecord.listen((record) {
captured = record;
});
const caretLog =
r'12:34:56 INFO dir/test.rs:42 \^[[3mreceipt_id\^[[0m\^[[2m=\^[[0m"d9891084" \^[[3mkind\^[[0m\^[[2m=\^[[0m"FlameSync" Handling incoming message';
LoggingCallbacks.handleRustLog(caretLog);
expect(captured, isNotNull);
expect(
captured!.message,
'receipt_id="d9891084" kind="FlameSync" Handling incoming message',
);
sub.cancel();
});
});
}

View file

@ -34,7 +34,6 @@ UserConfig testUserConfig({
screenLockEnabled: false,
isCloudBackupEnabled: false,
isUserDiscoveryEnabled: false,
sealedSenderEnabled: true,
requiredSendImages: 4,
userDiscoveryThreshold: 3,
userDiscoveryRequiresManualApproval: false,