remove unused database notification

This commit is contained in:
otsmr 2026-08-31 21:31:55 +02:00
parent bf802e5540
commit 8c2e568775
55 changed files with 842 additions and 435 deletions

View file

@ -12,8 +12,8 @@ import 'package:twonly/src/utils/log.dart';
/// learns about it, so `watch()` keeps serving stale rows and the UI does not /// learns about it, so `watch()` keeps serving stale rows and the UI does not
/// update until something else happens to touch the same table. /// update until something else happens to touch the same table.
/// ///
/// Rust already broadcasts the tables it commits to (`notify_committed`); this /// SQLite's own commit hook broadcasts the tables each committed transaction
/// forwards those batches into [GeneratedDatabase.notifyUpdates]. /// touched; this forwards those batches into [GeneratedDatabase.notifyUpdates].
StreamSubscription<List<String>> listenToRustDatabaseChanges( StreamSubscription<List<String>> listenToRustDatabaseChanges(
GeneratedDatabase db, GeneratedDatabase db,
) { ) {

View file

@ -29,10 +29,20 @@ class ApiService {
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription; StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
late final StreamSubscription<ApiEvent> _apiEventSubscription; late final StreamSubscription<ApiEvent> _apiEventSubscription;
/// The server rejects an outdated app or a superseded device during the
/// handshake, which usually happens before the widget showing that banner is
/// mounted. [events] is a broadcast stream, so late listeners would miss it
/// they read the last rejection from here instead.
ApiEventKind? permanentRejection;
Future<void> _handleApiEvent(ApiEvent event) async { Future<void> _handleApiEvent(ApiEvent event) async {
if (event.kind == ApiEventKind.authenticated) { if (event.kind == ApiEventKind.authenticated) {
await onAuthenticated(); await onAuthenticated();
} }
if (event.kind == ApiEventKind.appOutdated ||
event.kind == ApiEventKind.newDeviceRegistered) {
permanentRejection = event.kind;
}
} }
// Function is called after the user is authenticated at the server // Function is called after the user is authenticated at the server

View file

@ -30,19 +30,25 @@ class _AppOutdatedCompState extends State<AppOutdatedComp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// The rejection is sent during the API handshake, which regularly completes
// before this widget is mounted, so start from the last one the service saw.
_showRejection(apiService.permanentRejection);
_apiEventSubscription = apiService.events.listen((event) async { _apiEventSubscription = apiService.events.listen((event) async {
if (!mounted) return; if (!mounted) return;
if (event.kind == ApiEventKind.appOutdated || if (event.kind == ApiEventKind.appOutdated ||
event.kind == ApiEventKind.newDeviceRegistered) { event.kind == ApiEventKind.newDeviceRegistered) {
await context.read<CustomChangeProvider>().updateConnectionState(false); await context.read<CustomChangeProvider>().updateConnectionState(false);
setState(() { if (!mounted) return;
appIsOutdated = event.kind == ApiEventKind.appOutdated; setState(() => _showRejection(event.kind));
newDeviceRegistered = event.kind == ApiEventKind.newDeviceRegistered;
});
} }
}); });
} }
void _showRejection(ApiEventKind? kind) {
appIsOutdated = kind == ApiEventKind.appOutdated;
newDeviceRegistered = kind == ApiEventKind.newDeviceRegistered;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (newDeviceRegistered) { if (newDeviceRegistered) {

View file

@ -35,6 +35,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_in
class _MessageAnimationState { class _MessageAnimationState {
bool hasReceivedFirstBatch = false; bool hasReceivedFirstBatch = false;
DateTime? newestKnownAt;
final HashSet<String> knownMessageIds = HashSet<String>(); final HashSet<String> knownMessageIds = HashSet<String>();
final HashSet<String> animateMessageIds = HashSet<String>(); final HashSet<String> animateMessageIds = HashSet<String>();
final HashSet<String> reportedOpenedMessageIds = HashSet<String>(); final HashSet<String> reportedOpenedMessageIds = HashSet<String>();
@ -379,13 +380,22 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
List<GroupHistory> groupActions, { List<GroupHistory> groupActions, {
bool reportOpened = false, bool reportOpened = false,
}) async { }) async {
final newestKnownAt = _animationState.newestKnownAt;
for (final msg in newMessages) { for (final msg in newMessages) {
// Only messages appended after the newest one already loaded are new to
// the user. Messages fetched by scrolling up are older and must not
// animate, no matter who sent them.
if (_animationState.hasReceivedFirstBatch && if (_animationState.hasReceivedFirstBatch &&
!_animationState.knownMessageIds.contains(msg.messageId) && newestKnownAt != null &&
msg.senderId == null) { msg.createdAt.isAfter(newestKnownAt) &&
!_animationState.knownMessageIds.contains(msg.messageId)) {
_animationState.animateMessageIds.add(msg.messageId); _animationState.animateMessageIds.add(msg.messageId);
} }
_animationState.knownMessageIds.add(msg.messageId); _animationState.knownMessageIds.add(msg.messageId);
final currentNewest = _animationState.newestKnownAt;
if (currentNewest == null || msg.createdAt.isAfter(currentNewest)) {
_animationState.newestKnownAt = msg.createdAt;
}
} }
final chatItems = <ChatItem>[]; final chatItems = <ChatItem>[];
@ -680,6 +690,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
key: Key('anim_${chatMessage.messageId}'), key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId, messageId: chatMessage.messageId,
animateIds: _animationState.animateMessageIds, animateIds: _animationState.animateMessageIds,
isOwnMessage: chatMessage.senderId == null,
child: ChatListEntry( child: ChatListEntry(
key: Key(chatMessage.messageId), key: Key(chatMessage.messageId),
message: _data.chatItems[i].message!, message: _data.chatItems[i].message!,

View file

@ -5,6 +5,7 @@ class AnimatedNewMessage extends StatefulWidget {
required this.child, required this.child,
required this.messageId, required this.messageId,
required this.animateIds, required this.animateIds,
required this.isOwnMessage,
super.key, super.key,
}); });
@ -12,6 +13,9 @@ class AnimatedNewMessage extends StatefulWidget {
final String messageId; final String messageId;
final Set<String> animateIds; final Set<String> animateIds;
/// Own messages grow out of the right edge, received ones out of the left.
final bool isOwnMessage;
@override @override
State<AnimatedNewMessage> createState() => _AnimatedNewMessageState(); State<AnimatedNewMessage> createState() => _AnimatedNewMessageState();
} }
@ -79,7 +83,9 @@ class _AnimatedNewMessageState extends State<AnimatedNewMessage>
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
child: ScaleTransition( child: ScaleTransition(
scale: _scaleAnimation, scale: _scaleAnimation,
alignment: Alignment.bottomRight, alignment: widget.isOwnMessage
? Alignment.bottomRight
: Alignment.bottomLeft,
child: FadeTransition( child: FadeTransition(
opacity: _opacityAnimation, opacity: _opacityAnimation,
child: widget.child, child: widget.child,

View file

@ -71,18 +71,26 @@ class _MessageInputState extends State<MessageInput> {
Timer? _recordingTimer; Timer? _recordingTimer;
DateTime? _recordingStartTime; DateTime? _recordingStartTime;
Future<void> _sendMessage() async { void _sendMessage() {
if (_textFieldController.text == '') return; final text = _textFieldController.text;
if (text == '') return;
await RustApi.insertAndSendText( final quoteMessageId = widget.quotesMessage?.messageId;
groupId: widget.group.groupId,
text: _textFieldController.text,
quoteMessageId: widget.quotesMessage?.messageId,
);
// Emptying the composer is not allowed to wait on the bridge: Rust commits
// the message row before it starts delivering, so the bubble is already on
// its way into the chat list while this call is still running.
_textFieldController.clear(); _textFieldController.clear();
widget.onMessageSend(); widget.onMessageSend();
setState(() {}); setState(() {});
unawaitedRustCall(
RustApi.insertAndSendText(
groupId: widget.group.groupId,
text: text,
quoteMessageId: quoteMessageId,
),
'insertAndSendText',
);
} }
@override @override

View file

@ -1,21 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND sealed_sender_enabled = 1)",
"describe": {
"columns": [
{
"name": "EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND sealed_sender_enabled = 1)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "0e2dcd586093544fb7c0163785648b2e2510cc8ca43f9f418e60bfc5afb40d48"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM message_actions\n WHERE message_id = ? AND contact_id = ? AND type = 'sealedSenderAt'",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1c0946be221d955a0082ba67f7041bb914b6f3a319967765240c1ac958768006"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_discovery_shares WHERE 1",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1e99ff0bf394ff271be3173e86c86f164ec766a30c36bcc3984164005786e095"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE received_receipts\n SET pending_plaintext = ?\n WHERE receipt_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "231815a391f5a44481e92b316c927f8e9e21940fb6d089fb1b8871d756577db3"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO privacy_pass_tokens(token, expires_at) VALUES (?, ?)\n ON CONFLICT(token) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "30e32daf98cb4d71d84b79f214d828524a4450590d72e2310eb89bb7033f4399"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO contacts(user_id, username, avatar_svg_compressed) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "36d08507990dc90860bde55e44e28a2e7637c422eb966d0a0b89b97ec7a9a8fb"
}

View file

@ -1,21 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM privacy_pass_tokens WHERE expires_at > ?",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "3ef50101727d2255e0ae23e4151336652a52d58403ce2e2552b53de6cb8b8743"
}

View file

@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT sealed_sender_enabled FROM contacts WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "sealed_sender_enabled",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "sealed_sender_enabled"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "407232f4e5dae0ed496d80369d0bb5f158b7fadb9b74db4fb922e26af0443a63"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO contacts(user_id, username) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "4bd3233fbadc3e75f3b929571fa9e74d681510c978d27da3b6a132ca7e402c7a"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO signal_identities (name, identity_key, timestamp)\n VALUES (?, ?, 0)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "4d18233931fbadbfc01254449b9a9dd1913ecf014c117f3b7d77e6d8267dd7d6"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT pending_plaintext\n FROM received_receipts\n WHERE receipt_id = ?\n ",
"describe": {
"columns": [
{
"name": "pending_plaintext",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "received_receipts",
"name": "pending_plaintext"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "6eef41e5ed8b8d8b3c390baadc3fc4df47a128d2466e7fa9f224c1365501dd0b"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO message_actions(message_id, contact_id, type)\n VALUES (?, ?, 'sealedSenderAt')\n ON CONFLICT(message_id, contact_id, type)\n DO UPDATE SET action_at = CAST(strftime('%s', 'now') AS INTEGER)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "738756d95a2482031a39318684279522c5628f5b5e61a45904283734e14d645a"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE contacts SET sealed_sender_enabled = ?\n WHERE user_id = ? AND sealed_sender_enabled != ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "74c9a8f699b6a1c214e196824b9ac482d6f7165af2fa5a2e275facb310acdaee"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET download_state = 'ready', stored_file_hash = ?,\n size_in_bytes = ?\n WHERE media_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b579b1074dec0274fa72658bcb0843bc98cc0adb75add13334576af560cbfb64"
}

View file

@ -288,17 +288,6 @@
"name": "media_received_counter" "name": "media_received_counter"
} }
} }
},
{
"name": "sealed_sender_enabled",
"ordinal": 26,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "sealed_sender_enabled"
}
}
} }
], ],
"parameters": { "parameters": {
@ -330,7 +319,6 @@
true, true,
true, true,
false, false,
false,
false false
] ]
}, },

View file

@ -1,21 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM message_actions\n WHERE message_id = ? AND contact_id = ? AND type = 'sealedSenderAt')",
"describe": {
"columns": [
{
"name": "EXISTS(SELECT 1 FROM message_actions\n WHERE message_id = ? AND contact_id = ? AND type = 'sealedSenderAt')",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "c91dc6e6cff692209fa18216e2b1ec547556fe464ceb3993d595f8f2c7d8532f"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_discovery_shares",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "d36767ac50047037ce2122bd59b84c94407918776b0ad2b8c47a1bfb041b620b"
}

View file

@ -1,21 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM privacy_pass_tokens",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "d84d22b95288a8fd4bbcb96bdddbfd69afb18384d91764009abb50610c959374"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET download_state = 'ready', stored_file_hash = ?\n WHERE media_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e24b46f465ca104c6f288eafda55b060d389a1a07f54757b0e12ac4d16cc2ec9"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM privacy_pass_tokens WHERE expires_at <= ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e33bd4d14da2ab7a0b083d894f911a582bd664e168ed16e0ff6adb7532bddaec"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE received_receipts\n SET pending_plaintext = NULL\n WHERE receipt_id = ? AND pending_plaintext IS NOT NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e9d9eb36852d29f511a54e707d3d5f8b5f6adc5fec457e6a32a6be51b4b0d122"
}

View file

@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM privacy_pass_tokens\n WHERE token = (\n SELECT token FROM privacy_pass_tokens\n WHERE expires_at > ?\n ORDER BY expires_at ASC\n LIMIT 1\n )\n RETURNING token",
"describe": {
"columns": [
{
"name": "token",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "privacy_pass_tokens",
"name": "token"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f4af415f434d17c2880be292539d72ae5c0b8d1176a6c9860716832c1d82885b"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT avatar_svg_compressed, sender_profile_counter FROM contacts WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "avatar_svg_compressed",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "contacts",
"name": "avatar_svg_compressed"
}
}
},
{
"name": "sender_profile_counter",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "sender_profile_counter"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "fbc5fa19cad3ddde6098197c6d58d103efaa97f89c7a3fa9d5ed4c8292a246a4"
}

View file

@ -1,17 +1,17 @@
use std::io::Result; use std::io::Result;
fn main() -> Result<()> { fn main() -> Result<()> {
// The version the server compares against its minimum version setting.
// A build that cannot determine it has to fail here rather than ship a
// placeholder, which the server would read as an ancient client.
println!("cargo:rerun-if-changed=../pubspec.yaml"); println!("cargo:rerun-if-changed=../pubspec.yaml");
if let Ok(pubspec) = std::fs::read_to_string("../pubspec.yaml") { let pubspec =
if let Some(version) = pubspec std::fs::read_to_string("../pubspec.yaml").expect("could not read ../pubspec.yaml");
let version = pubspec
.lines() .lines()
.find_map(|line| line.strip_prefix("version: ")) .find_map(|line| line.strip_prefix("version: "))
{ .map(|version| version.split('+').next().unwrap_or(version).trim())
println!( .expect("no `version:` entry in ../pubspec.yaml");
"cargo:rustc-env=TWONLY_APP_VERSION={}", println!("cargo:rustc-env=TWONLY_APP_VERSION={version}");
version.split('+').next().unwrap_or(version)
);
}
}
let websocket_proto_root = "src/api/proto"; let websocket_proto_root = "src/api/proto";
let websocket_protos = [ let websocket_protos = [
"src/api/proto/api/websocket/client_to_server.proto", "src/api/proto/api/websocket/client_to_server.proto",

View file

@ -90,10 +90,12 @@ pub(crate) async fn handle_server_message(
/// left out is redelivered on the next drain. /// left out is redelivered on the next drain.
/// ///
/// Deduplication is the `received_receipts` claim inside /// Deduplication is the `received_receipts` claim inside
/// [`handle_decoded_server_message`]: it is committed in the same transaction /// [`handle_decoded_server_message`]: it is keyed on the end-to-end receipt ID
/// that persists the message, is keyed on the end-to-end receipt ID, and is /// and is never purged, so a redelivered envelope is recognised before it is
/// never purged. A redelivered envelope is therefore recognised before it is /// decrypted, whichever transport carried it. An encrypted message commits its
/// decrypted, whichever transport carried it. /// claim together with its plaintext one step ahead of the message itself,
/// because the ratchet step decryption spends cannot be rolled back; a retry
/// that finds that plaintext resumes from it instead of decrypting again.
async fn acknowledge_pending_messages( async fn acknowledge_pending_messages(
ctx: &Arc<Context>, ctx: &Arc<Context>,
batch: server_to_client::PendingMessagesV2, batch: server_to_client::PendingMessagesV2,
@ -114,14 +116,18 @@ async fn acknowledge_pending_messages(
match result { match result {
// Committed, or recognised as a duplicate. Either way it is durable. // Committed, or recognised as a duplicate. Either way it is durable.
Ok(()) => delivery_ids.push(delivery_id), Ok(()) => delivery_ids.push(delivery_id),
// An envelope that cannot be decoded will never decode. Acknowledge // An envelope that cannot be decoded, or content this client will
// it so one poisoned row cannot be redelivered forever. // never be able to act on, stays broken however often it is
// redelivered. Acknowledge it so one poisoned row cannot be
// redelivered forever.
Err( Err(
error @ (TwonlyError::ProtobufDecode(_) | TwonlyError::UnknownProtobufEnumValue(_)), error @ (TwonlyError::ProtobufDecode(_)
| TwonlyError::UnknownProtobufEnumValue(_)
| TwonlyError::UnprocessableContent(_)),
) => { ) => {
tracing::warn!( tracing::warn!(
delivery_id, delivery_id,
"dropping an undecodable mailbox message: {error}" "dropping an unprocessable mailbox message: {error}"
); );
delivery_ids.push(delivery_id); delivery_ids.push(delivery_id);
} }
@ -225,7 +231,6 @@ async fn upgrade_legacy_session_to_v2(
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["contacts"]);
Ok(()) Ok(())
} }
@ -247,7 +252,6 @@ pub(crate) async fn handle_decoded_server_message(
if let Ok(user) = ctx.user_id().await { if let Ok(user) = ctx.user_id().await {
tracing::Span::current().record("user", user); tracing::Span::current().record("user", user);
} }
tracing::info!("Started processing incoming message");
if message.receipt_id.is_empty() { if message.receipt_id.is_empty() {
return Err(TwonlyError::Generic( return Err(TwonlyError::Generic(
@ -260,10 +264,14 @@ pub(crate) async fn handle_decoded_server_message(
message_type, message_type,
Type::Ciphertext | Type::PrekeyBundle | Type::CiphertextV2 Type::Ciphertext | Type::PrekeyBundle | Type::CiphertextV2
); );
tracing::info!(is_encrypted_message, ?message_type, "Parsed message type");
tracing::info!(
is_encrypted_message,
?message_type,
"Parsed incoming message type"
);
if is_encrypted_message { if is_encrypted_message {
tracing::info!("Ensuring contact exists...");
ensure_contact_exists(ctx, from_user_id).await?; ensure_contact_exists(ctx, from_user_id).await?;
} }
@ -280,7 +288,20 @@ pub(crate) async fn handle_decoded_server_message(
let claimed = Receipt::claim_received(&mut t, &message.receipt_id).await?; let claimed = Receipt::claim_received(&mut t, &message.receipt_id).await?;
if !claimed { // A claim that was committed with a plaintext still parked on it belongs to
// a message whose handling did not commit. Its ratchet step is spent, so the
// redelivery has to resume from that plaintext instead of decrypting again.
let resumed_plaintext = if claimed || message_type != Type::CiphertextV2 {
None
} else {
Receipt::pending_plaintext(&mut t, &message.receipt_id).await?
};
if resumed_plaintext.is_some() {
tracing::info!("Resuming a redelivered message from its parked plaintext");
}
if !claimed && resumed_plaintext.is_none() {
// Delivery receipts are terminal messages and must never themselves be // Delivery receipts are terminal messages and must never themselves be
// acknowledged. For regular messages Dart retries the delivery receipt // acknowledged. For regular messages Dart retries the delivery receipt
// after ten days, atomically claiming the retry by moving created_at. // after ten days, atomically claiming the retry by moving created_at.
@ -315,19 +336,34 @@ pub(crate) async fn handle_decoded_server_message(
sends_error_response = true; sends_error_response = true;
} }
Type::CiphertextV2 => { Type::CiphertextV2 => {
let decrypted = match resumed_plaintext {
Some(plaintext) => Ok(plaintext),
None => {
let ciphertext = message.encrypted_content.ok_or_else(|| { let ciphertext = message.encrypted_content.ok_or_else(|| {
TwonlyError::Generic("V2 encrypted client message has no ciphertext".into()) TwonlyError::UnprocessableContent(
"V2 encrypted client message has no ciphertext".into(),
)
})?; })?;
let decrypted = {
let engine = ctx.signal_engine.lock().await; let engine = ctx.signal_engine.lock().await;
engine engine
.as_ref() .as_ref()
.ok_or(TwonlyError::SignalIdentityNotFound)? .ok_or(TwonlyError::SignalIdentityNotFound)?
.decrypt_message(from_user_id.to_string(), 1, ciphertext) .decrypt_message(from_user_id.to_string(), 1, ciphertext)
.await .await
}
}; };
match decrypted { match decrypted {
Ok(plaintext) => { Ok(plaintext) => {
// Decryption consumed a ratchet step in the signal database,
// which this transaction cannot roll back. Commit the
// plaintext with the claim first, so a rollback further down
// leaves a redelivery something to resume from instead of a
// session that can no longer decrypt the message.
Receipt::store_pending_plaintext(&mut t, &message.receipt_id, &plaintext)
.await?;
t.commit().await?;
t = database.pool.begin().await?;
// Decrypting proves the peer speaks v2, so drop any 'v1' // Decrypting proves the peer speaks v2, so drop any 'v1'
// marking left by a contact lookup that ran before the peer // marking left by a contact lookup that ran before the peer
// published a prekey bundle. Otherwise every receipt back to // published a prekey bundle. Otherwise every receipt back to
@ -350,7 +386,7 @@ pub(crate) async fn handle_decoded_server_message(
let content = let content =
proto::EncryptedContent::decode(plaintext.as_slice()).map_err(|error| { proto::EncryptedContent::decode(plaintext.as_slice()).map_err(|error| {
TwonlyError::Generic(format!( TwonlyError::UnprocessableContent(format!(
"invalid decrypted client content: {error}" "invalid decrypted client content: {error}"
)) ))
})?; })?;
@ -389,19 +425,13 @@ pub(crate) async fn handle_decoded_server_message(
queue_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?; queue_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?;
} }
// The message is about to become durable, so the plaintext parked for a
// retry that is no longer needed can go.
Receipt::clear_pending_plaintext(&mut t, &message.receipt_id).await?;
t.commit().await?; t.commit().await?;
ctx.mark_incoming_committed(); ctx.mark_incoming_committed();
database.notify_committed([
"received_receipts",
"receipts",
"messages",
"groups",
"contacts",
"key_verifications",
"user_discovery_own_promotions",
"notification_outbox",
]);
let ctx = ctx.clone(); let ctx = ctx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -512,9 +542,15 @@ async fn handle_encrypted_inner(
return recovery::handle_passwordless_recovery_heartbeat(t, from_user_id, heartbeat).await; return recovery::handle_passwordless_recovery_heartbeat(t, from_user_id, heartbeat).await;
} }
let group_id = content let Some(group_id) = content.group_id else {
.group_id // Everything below is group-scoped. Reaching here without a group ID is
.ok_or_else(|| TwonlyError::Generic("group-scoped message has no group ID".into()))?; // normal for a content that only carries sender metadata (a profile
// counter, a user-discovery version, a friend-promotion request), all of
// which has already been applied above. Failing it would only make the
// server redeliver a message there is nothing left to do with.
tracing::info!("Incoming message carried sender metadata only");
return Ok(());
};
if let Some(create) = content.group_create { if let Some(create) = content.group_create {
return groups::handle_group_create(ctx, t, from_user_id, &group_id, create).await; return groups::handle_group_create(ctx, t, from_user_id, &group_id, create).await;
@ -610,7 +646,7 @@ async fn handle_encrypted_inner(
.await; .await;
} }
Err(TwonlyError::Generic(format!( Err(TwonlyError::UnprocessableContent(format!(
"client2client content in receipt {receipt_id} is not implemented in Rust" "client2client content in receipt {receipt_id} is not implemented in Rust"
))) )))
} }

View file

@ -216,7 +216,6 @@ pub(crate) async fn ensure_contact_exists(ctx: &Arc<Context>, from_user_id: i64)
.await?; .await?;
} }
db_app.notify_committed(["contacts"]);
Ok(()) Ok(())
} }
@ -448,7 +447,6 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
// on every retry. Drop the receipt instead of queueing it forever. // on every retry. Drop the receipt instead of queueing it forever.
if row.account_deleted != 0 { if row.account_deleted != 0 {
Receipt::delete(&app_db.pool, receipt_id).await?; Receipt::delete(&app_db.pool, receipt_id).await?;
app_db.notify_committed(["receipts"]);
return Ok(()); return Ok(());
} }
@ -528,7 +526,6 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
.await?; .await?;
} }
t.commit().await?; t.commit().await?;
app_db.notify_committed(["receipts", "message_actions", "messages"]);
Ok(()) Ok(())
} }
@ -552,7 +549,6 @@ async fn defer_receipt_until_session(
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["receipts"]);
Ok(()) Ok(())
} }

View file

@ -129,7 +129,6 @@ pub async fn send_c2c_message_to_contact(
.await?; .await?;
t.commit().await?; t.commit().await?;
db_app.notify_committed(["receipts"]);
if only_return_encrypted_data { if only_return_encrypted_data {
return messages::prepare_queued_receipt(ctx, &receipt_id).await; return messages::prepare_queued_receipt(ctx, &receipt_id).await;

View file

@ -145,7 +145,6 @@ impl ApiRuntime {
} }
} }
} }
database.notify_committed(["api_outbox"]);
Ok(()) Ok(())
} }

View file

@ -5,6 +5,7 @@
use super::client::ApiClient; use super::client::ApiClient;
use super::helpers::{decode_ok, schedule_post_authentication}; use super::helpers::{decode_ok, schedule_post_authentication};
use super::request::permanent_rejection_kind;
use crate::api::proto::{client_to_server, server_to_client}; use crate::api::proto::{client_to_server, server_to_client};
use crate::bridge::api::{ApiConnectionState, ApiEvent, ApiEventKind, ServerResult}; use crate::bridge::api::{ApiConnectionState, ApiEvent, ApiEventKind, ServerResult};
use crate::context::Context; use crate::context::Context;
@ -21,6 +22,14 @@ use stream_tungstenite::handshake::{HandshakeReceiver, HandshakeSender, Handshak
use stream_tungstenite::tokio_tungstenite::tungstenite; use stream_tungstenite::tokio_tungstenite::tungstenite;
use tokio::sync::broadcast; use tokio::sync::broadcast;
/// The app version from `pubspec.yaml` without the build number, baked in by
/// `build.rs`. This is what the handshake reports to the server, which compares
/// it against its configured minimum version.
///
/// Not to be confused with `UserConfig::app_version`, which is the local
/// migration id and stays a purely client side concern.
pub(crate) const APP_VERSION: &str = env!("TWONLY_APP_VERSION");
pub(crate) struct ApiAuthHandshaker { pub(crate) struct ApiAuthHandshaker {
pub context: Arc<Context>, pub context: Arc<Context>,
pub api_client: Weak<ApiClient>, pub api_client: Weak<ApiClient>,
@ -34,8 +43,7 @@ impl ApiAuthHandshaker {
let user = UserConfig::load_from(&self.context)? let user = UserConfig::load_from(&self.context)?
.ok_or_else(|| TwonlyError::Generic("User configuration not found".into()))?; .ok_or_else(|| TwonlyError::Generic("User configuration not found".into()))?;
let device_id = user.device_id; let device_id = user.device_id;
let app_version = user.app_version.to_string(); Ok((Some(user.user_id), device_id, APP_VERSION.to_string()))
Ok((Some(user.user_id), device_id, app_version))
} }
async fn request_handshake( async fn request_handshake(
@ -76,6 +84,17 @@ impl ApiAuthHandshaker {
let ok = match ok_res { let ok = match ok_res {
ServerResult::Ok(val) => val, ServerResult::Ok(val) => val,
ServerResult::ErrorCode(code) => { ServerResult::ErrorCode(code) => {
// The handshake never reaches `handle_api_error`, so a rejection
// that no reconnect can fix has to be published here — otherwise
// the app just keeps failing to connect without telling the user
// that it is outdated or was logged out.
if let Some(kind) = permanent_rejection_kind(code) {
if let Some(client) = self.api_client.upgrade() {
// Closing the socket has to wait for this handshake to
// return, so it cannot be awaited from inside it.
tokio::spawn(async move { client.reject_permanently(kind).await });
}
}
return Err(HandshakeError::Protocol(format!( return Err(HandshakeError::Protocol(format!(
"Server returned error code: {}", "Server returned error code: {}",
code code
@ -286,6 +305,7 @@ impl ApiAuthHandshaker {
self.is_authenticated.store(true, Ordering::Release); self.is_authenticated.store(true, Ordering::Release);
if let Some(client) = self.api_client.upgrade() { if let Some(client) = self.api_client.upgrade() {
client.note_authenticated();
tokio::spawn(async move { tokio::spawn(async move {
client.set_state(ApiConnectionState::Authenticated).await; client.set_state(ApiConnectionState::Authenticated).await;
// Runs on every (re)connect, so this covers both the initial // Runs on every (re)connect, so this covers both the initial
@ -420,3 +440,42 @@ impl ApiClient {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::APP_VERSION;
/// The server splits the reported version on '.' and parses every segment
/// as a number, so a suffix like "0.5.2-beta" would silently be read as
/// 0.5.0 and could fall below the configured minimum version.
#[test]
fn app_version_is_a_plain_dotted_version() {
let segments: Vec<&str> = APP_VERSION.split('.').collect();
assert!(
!segments.is_empty() && segments.len() <= 3,
"unexpected app version {APP_VERSION}"
);
for segment in segments {
assert!(
segment.parse::<u32>().is_ok(),
"app version {APP_VERSION} has a non numeric segment {segment}"
);
}
}
/// Guards the `pubspec.yaml` parsing in `build.rs`.
#[test]
fn app_version_matches_the_pubspec() {
let pubspec = std::fs::read_to_string("../pubspec.yaml").expect("read ../pubspec.yaml");
let version = pubspec
.lines()
.find_map(|line| line.strip_prefix("version: "))
.expect("no `version:` entry in ../pubspec.yaml");
assert_eq!(
APP_VERSION,
version.split('+').next().unwrap_or(version).trim()
);
}
}

View file

@ -7,7 +7,7 @@ use crate::bridge::api::{ApiConfig, ApiConnectionState, ApiEvent, ApiEventKind};
use crate::context::Context; use crate::context::Context;
use crate::error::Result; use crate::error::Result;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, LazyLock, Weak}; use std::sync::{Arc, LazyLock, Weak};
use std::time::Duration; use std::time::Duration;
use stream_tungstenite::{ClientConfig, WebSocketClient}; use stream_tungstenite::{ClientConfig, WebSocketClient};
@ -29,6 +29,14 @@ const CATCH_UP_JITTER: Duration = Duration::from_secs(15);
/// messages. /// messages.
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_millis(250); const RECONNECT_INITIAL_DELAY: Duration = Duration::from_millis(250);
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(20); const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(20);
/// A socket whose transport has died does not reconnect by itself: a failed
/// handshake makes the supervisor give up for good, and it leaves the send
/// channel behind so every later send reports a closed channel. These bound
/// the redial we drive ourselves, backing off so a handshake that keeps
/// failing (a rejected token, say) does not turn into a dial loop.
const FORCED_RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(1);
const FORCED_RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(45); const RECEIVE_TIMEOUT: Duration = Duration::from_secs(45);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const HANDSHAKE_RETRY_DELAY: Duration = Duration::from_secs(1); const HANDSHAKE_RETRY_DELAY: Duration = Duration::from_secs(1);
@ -53,6 +61,12 @@ pub(crate) struct ApiClient {
/// `try_send`, so repeated triggers collapse into a single pull instead of /// `try_send`, so repeated triggers collapse into a single pull instead of
/// queueing one request each. /// queueing one request each.
catch_up_tx: Mutex<Option<mpsc::Sender<()>>>, catch_up_tx: Mutex<Option<mpsc::Sender<()>>>,
/// Set while a forced redial is pending, so the many sends that fail
/// against one dead socket schedule a single reconnect between them.
forced_reconnect_in_flight: AtomicBool,
/// Counts forced redials since the last authenticated session; feeds the
/// backoff in [`ApiClient::schedule_reconnect`].
forced_reconnect_attempts: AtomicU32,
} }
impl ApiClient { impl ApiClient {
@ -71,6 +85,8 @@ impl ApiClient {
network_available: AtomicBool::new(true), network_available: AtomicBool::new(true),
is_authenticated: Arc::new(AtomicBool::new(false)), is_authenticated: Arc::new(AtomicBool::new(false)),
catch_up_tx: Mutex::const_new(None), catch_up_tx: Mutex::const_new(None),
forced_reconnect_in_flight: AtomicBool::new(false),
forced_reconnect_attempts: AtomicU32::new(0),
}) })
} }
@ -143,6 +159,101 @@ impl ApiClient {
}); });
} }
/// Discards a socket the transport has declared dead and dials a fresh one.
///
/// The supervisor cannot do this itself: it treats every failure our
/// handshaker reports as permanent, so it stops reconnecting, and because
/// that path skips its session cleanup it leaves the send channel in place
/// with no reader behind it. `ws_client` therefore still holds a client
/// that looks alive while every send fails with `ChannelClosed`, and
/// `connect` returns early on it. Only replacing it recovers.
///
/// `stale` is the connection the caller failed on; if the slot already
/// holds a different one, someone reconnected in the meantime and this is
/// a no-op. The redial runs detached so the caller can return its own
/// error right away — whatever failed to send is retried by the
/// post-authentication receipt sweep once the new socket is up.
pub(crate) fn schedule_reconnect(self: &Arc<Self>, stale: &Arc<WebSocketClient>, reason: &str) {
if self.deliberately_closed.load(Ordering::Acquire)
|| API_PERMANENTLY_REJECTED.load(Ordering::Acquire)
{
return;
}
// Every send against the dead socket lands here; only the first one
// gets to schedule the redial.
if self.forced_reconnect_in_flight.swap(true, Ordering::AcqRel) {
return;
}
let client = self.clone();
let stale = stale.clone();
let reason = reason.to_owned();
tokio::spawn(async move {
let result = client.reconnect(&stale, &reason).await;
client
.forced_reconnect_in_flight
.store(false, Ordering::Release);
if let Err(error) = result {
tracing::warn!("reconnect after a dead API WebSocket failed: {error}");
}
});
}
async fn reconnect(self: &Arc<Self>, stale: &Arc<WebSocketClient>, reason: &str) -> Result<()> {
{
let mut guard = self.ws_client.lock().await;
if !guard
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, stale))
{
return Ok(());
}
guard.take();
}
// Counted only for a socket that was still the live one, so a stale
// report cannot inflate the backoff.
let attempt = self
.forced_reconnect_attempts
.fetch_add(1, Ordering::AcqRel);
let delay = FORCED_RECONNECT_INITIAL_DELAY
.saturating_mul(1_u32 << attempt.min(6))
.min(FORCED_RECONNECT_MAX_DELAY);
tracing::warn!(
reason,
attempt,
?delay,
"API WebSocket is dead, reconnecting"
);
self.is_authenticated.store(false, Ordering::Release);
// Dropping the sender ends the catch-up loop bound to the dead socket.
*self.catch_up_tx.lock().await = None;
if let Err(error) = stale.shutdown_graceful(Duration::from_secs(5)).await {
tracing::warn!("dead WebSocket did not shut down cleanly: {error}");
}
self.fail_pending().await;
self.set_state(ApiConnectionState::Stopped).await;
tokio::time::sleep(delay).await;
// Closing the client or losing the network during the delay means the
// redial is no longer wanted.
if self.deliberately_closed.load(Ordering::Acquire)
|| !self.network_available.load(Ordering::Acquire)
{
return Ok(());
}
self.connect().await
}
/// Restarts the forced-reconnect backoff. A session that got all the way to
/// an authenticated handshake proves the credentials and the server are
/// fine, so the next dead socket starts over at the short delay.
pub(crate) fn note_authenticated(&self) {
self.forced_reconnect_attempts.store(0, Ordering::Release);
}
pub(crate) async fn set_state(&self, state: ApiConnectionState) { pub(crate) async fn set_state(&self, state: ApiConnectionState) {
let mut guard = self.state.write().await; let mut guard = self.state.write().await;
if *guard != state { if *guard != state {
@ -221,6 +332,7 @@ impl ApiClient {
}); });
let self_clone = self.clone(); let self_clone = self.clone();
let connection = Arc::downgrade(&ws_arc);
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::select! { tokio::select! {
@ -258,6 +370,20 @@ impl ApiClient {
Ok(ConnectionEvent::Connecting { .. }) => { Ok(ConnectionEvent::Connecting { .. }) => {
self_clone.set_state(ApiConnectionState::Connecting).await; self_clone.set_state(ApiConnectionState::Connecting).await;
} }
// The supervisor has stopped reconnecting: it
// treats every handshake failure as permanent, and
// that path leaves the send channel behind with no
// reader, so this socket can neither reconnect nor
// send. Replace it instead of sitting on it.
Ok(ConnectionEvent::FatalError { .. } | ConnectionEvent::Shutdown) => {
self_clone.is_authenticated.store(false, Ordering::Release);
if let Some(connection) = connection.upgrade() {
self_clone.schedule_reconnect(
&connection,
"supervisor stopped reconnecting",
);
}
}
Ok(_) => {} Ok(_) => {}
Err(_) => break, Err(_) => break,
} }

View file

@ -30,6 +30,29 @@ fn call_handle_server_message(
}) })
} }
/// Maps the error codes that make further connection attempts pointless onto
/// the event the UI shows for them. Everything else is a per-request error.
pub(crate) fn permanent_rejection_kind(code: i32) -> Option<ApiEventKind> {
use crate::api::proto::error::ErrorCode;
if code == ErrorCode::AppVersionOutdated as i32 {
Some(ApiEventKind::AppOutdated)
} else if code == ErrorCode::NewDeviceRegistered as i32 {
Some(ApiEventKind::NewDeviceRegistered)
} else {
None
}
}
/// Whether a send failure means the socket can never carry another frame.
/// `ChannelClosed` is the signature of a connection the supervisor abandoned
/// mid-handshake: it left the send channel in place but dropped the reader, so
/// every further send fails the same way until the client is replaced. The
/// other variants describe a single message or a connection that is still
/// coming up, and are left to the caller to retry.
fn is_dead_transport(error: &SendError) -> bool {
matches!(error, SendError::ChannelClosed)
}
impl ApiClient { impl ApiClient {
pub(crate) async fn handle_incoming(self: &Arc<Self>, bytes: &[u8]) { pub(crate) async fn handle_incoming(self: &Arc<Self>, bytes: &[u8]) {
let Ok(message) = server_to_client::ServerToClient::decode(bytes) else { let Ok(message) = server_to_client::ServerToClient::decode(bytes) else {
@ -90,7 +113,12 @@ impl ApiClient {
Err(SendError::NotConnected) if start.elapsed() < Duration::from_secs(10) => { Err(SendError::NotConnected) if start.elapsed() < Duration::from_secs(10) => {
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
} }
Err(e) => return Err(TwonlyError::Generic(format!("send error: {:?}", e))), Err(e) => {
if is_dead_transport(&e) {
self.schedule_reconnect(&client, "send failed");
}
return Err(TwonlyError::Generic(format!("send error: {:?}", e)));
}
} }
} }
} else { } else {
@ -103,7 +131,7 @@ impl ApiClient {
} }
pub(crate) async fn request_durable( pub(crate) async fn request_durable(
&self, self: &Arc<Self>,
bytes: Vec<u8>, bytes: Vec<u8>,
operation_kind: &str, operation_kind: &str,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
@ -129,13 +157,12 @@ impl ApiClient {
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["api_outbox"]);
} }
response response
} }
pub(crate) async fn request_internal( pub(crate) async fn request_internal(
&self, self: &Arc<Self>,
bytes: Vec<u8>, bytes: Vec<u8>,
timeout: Duration, timeout: Duration,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
@ -167,6 +194,9 @@ impl ApiClient {
} }
Err(e) => { Err(e) => {
self.pending.lock().await.remove(&sequence); self.pending.lock().await.remove(&sequence);
if is_dead_transport(&e) {
self.schedule_reconnect(&client, "request send failed");
}
return Err(TwonlyError::Generic(format!("send error: {:?}", e))); return Err(TwonlyError::Generic(format!("send error: {:?}", e)));
} }
} }
@ -218,20 +248,14 @@ impl ApiClient {
Ok(()) Ok(())
} }
pub(crate) async fn handle_api_error(&self, code: i32, contact_id: Option<i64>) -> Result<()> { /// Rejections the server will keep returning for as long as this
use crate::api::proto::error::ErrorCode; /// installation stays as it is: the socket is closed for good and the UI is
if code == ErrorCode::AppVersionOutdated as i32 /// told why, instead of the client reconnecting into the same error.
|| code == ErrorCode::NewDeviceRegistered as i32 pub(crate) async fn reject_permanently(&self, kind: ApiEventKind) {
{
self.set_state(ApiConnectionState::PermanentlyRejected) self.set_state(ApiConnectionState::PermanentlyRejected)
.await; .await;
API_PERMANENTLY_REJECTED.store(true, Ordering::Release); API_PERMANENTLY_REJECTED.store(true, Ordering::Release);
self.deliberately_closed.store(true, Ordering::Release); self.deliberately_closed.store(true, Ordering::Release);
let kind = if code == ErrorCode::AppVersionOutdated as i32 {
ApiEventKind::AppOutdated
} else {
ApiEventKind::NewDeviceRegistered
};
let _ = self.events.send(ApiEvent { let _ = self.events.send(ApiEvent {
kind, kind,
state: Some(ApiConnectionState::PermanentlyRejected), state: Some(ApiConnectionState::PermanentlyRejected),
@ -243,6 +267,12 @@ impl ApiClient {
} }
} }
} }
pub(crate) async fn handle_api_error(&self, code: i32, contact_id: Option<i64>) -> Result<()> {
use crate::api::proto::error::ErrorCode;
if let Some(kind) = permanent_rejection_kind(code) {
self.reject_permanently(kind).await;
}
if code == ErrorCode::UserIdNotFound as i32 { if code == ErrorCode::UserIdNotFound as i32 {
if let Some(contact_id) = contact_id { if let Some(contact_id) = contact_id {
let context = self.context.upgrade().ok_or(TwonlyError::Initialization)?; let context = self.context.upgrade().ok_or(TwonlyError::Initialization)?;
@ -258,7 +288,6 @@ impl ApiClient {
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["contacts", "receipts"]);
} }
} }
Ok(()) Ok(())

View file

@ -61,7 +61,6 @@ impl Server {
.await?; .await?;
} }
} }
database.notify_committed(["contacts"]);
Ok(()) Ok(())
} }

View file

@ -76,7 +76,6 @@ impl FlutterUserDiscovery {
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["user_discovery_own_promotions", "contacts"]);
Ok(()) Ok(())
}) })
.await .await

View file

@ -66,7 +66,6 @@ impl AppDatabase {
.execute(&mut *connection) .execute(&mut *connection)
.await; .await;
let report = import_result?; let report = import_result?;
self.notify_committed(APPLICATION_TABLES.iter().copied());
Ok(report) Ok(report)
} }

View file

@ -0,0 +1,12 @@
-- Signal decryption advances the double ratchet in the *signal* database, which
-- has nothing to do with the app-database transaction that guards the receipt
-- claim. Rolling that transaction back after a successful decryption therefore
-- un-claims the receipt without rewinding the ratchet: the server redelivers
-- the envelope and the retry dies with "message with old counter", losing the
-- message for good.
--
-- The plaintext is parked here in the same commit that claims the receipt, so a
-- retry resumes from it instead of decrypting a second time. It is cleared as
-- soon as the message has been handled.
ALTER TABLE received_receipts
ADD COLUMN pending_plaintext BLOB;

View file

@ -75,10 +75,15 @@ impl AppDatabase {
let (changes, _) = broadcast::channel(256); let (changes, _) = broadcast::channel(256);
// SQLite itself reports which tables a statement touched, so Drift's // SQLite itself reports which tables a statement touched, so Drift's
// query streams stay correct without every Rust write site having to // query streams stay correct without any write site having to announce
// remember an explicit `notify_committed`. Rows are collected as they // what it changed. Rows are collected as they are written and only
// are written and only published once the transaction commits, so a // published once the transaction commits, so a rollback never reaches
// rollback never reaches the UI. // the UI.
//
// The one write this cannot see is `DELETE FROM <table>` with no
// `WHERE`: SQLite's truncate optimization drops the rows without
// invoking the update hook. Give such a delete a `WHERE 1` so it takes
// the ordinary path.
let pending: Arc<Mutex<BTreeSet<String>>> = Arc::default(); let pending: Arc<Mutex<BTreeSet<String>>> = Arc::default();
let hook_pending = pending.clone(); let hook_pending = pending.clone();
let hook_changes = changes.clone(); let hook_changes = changes.clone();
@ -154,17 +159,6 @@ impl AppDatabase {
self.changes.subscribe() self.changes.subscribe()
} }
/// Publishes a change immediately, without waiting for a commit.
///
/// The SQLite hooks installed in [`AppDatabase::new`] already cover every
/// ordinary write. This stays for the cases they cannot see -- most notably
/// `DELETE FROM <table>` with no `WHERE`, which SQLite's truncate
/// optimization performs without invoking the update hook.
pub fn notify_committed<'a>(&self, tables: impl IntoIterator<Item = &'a str>) {
let tables = tables.into_iter().map(str::to_owned).collect();
let _ = self.changes.send(DatabaseChange { tables });
}
pub async fn raw_select(&self, statement: String, arguments: Vec<SqlValue>) -> Result<SqlRows> { pub async fn raw_select(&self, statement: String, arguments: Vec<SqlValue>) -> Result<SqlRows> {
let rows = bind_arguments(sqlx::query(AssertSqlSafe(statement)), arguments)? let rows = bind_arguments(sqlx::query(AssertSqlSafe(statement)), arguments)?
.fetch_all(&self.pool) .fetch_all(&self.pool)

View file

@ -94,6 +94,71 @@ impl Receipt {
Ok(claimed) Ok(claimed)
} }
/// Parks the plaintext of an already-decrypted message on its receipt
/// claim. Signal decryption is a one-shot operation — it advances the
/// double ratchet in the signal database, outside this transaction — so the
/// plaintext has to be committed together with the claim. A redelivery that
/// finds it here resumes handling instead of decrypting again, which would
/// fail with an old-counter error.
pub async fn store_pending_plaintext(
transaction: &mut Transaction<'_, Sqlite>,
receipt_id: &str,
plaintext: &[u8],
) -> Result<()> {
sqlx::query!(
r#"
UPDATE received_receipts
SET pending_plaintext = ?
WHERE receipt_id = ?
"#,
plaintext,
receipt_id,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
/// Returns the parked plaintext of a receipt whose handling did not commit.
pub async fn pending_plaintext(
transaction: &mut Transaction<'_, Sqlite>,
receipt_id: &str,
) -> Result<Option<Vec<u8>>> {
let plaintext = sqlx::query_scalar!(
r#"
SELECT pending_plaintext
FROM received_receipts
WHERE receipt_id = ?
"#,
receipt_id,
)
.fetch_optional(&mut **transaction)
.await?
.flatten();
Ok(plaintext)
}
/// Drops a parked plaintext once its message no longer needs it.
pub async fn clear_pending_plaintext(
transaction: &mut Transaction<'_, Sqlite>,
receipt_id: &str,
) -> Result<()> {
sqlx::query!(
r#"
UPDATE received_receipts
SET pending_plaintext = NULL
WHERE receipt_id = ? AND pending_plaintext IS NOT NULL
"#,
receipt_id,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
pub async fn claim_received_retry( pub async fn claim_received_retry(
transaction: &mut Transaction<'_, Sqlite>, transaction: &mut Transaction<'_, Sqlite>,
receipt_id: &str, receipt_id: &str,
@ -201,3 +266,58 @@ impl<'a> NewReceipt<'a> {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::database::app::AppDatabase;
#[tokio::test]
async fn a_parked_plaintext_survives_a_rolled_back_handling() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("app.sqlite");
let database = AppDatabase::new(path.to_str().unwrap(), None, false)
.await
.unwrap();
database.run_migrations().await.unwrap();
// Phase one: the claim and the plaintext of a message whose ratchet step
// is already spent are committed together.
let mut t = database.pool.begin().await.unwrap();
assert!(Receipt::claim_received(&mut t, "receipt").await.unwrap());
Receipt::store_pending_plaintext(&mut t, "receipt", b"decrypted")
.await
.unwrap();
t.commit().await.unwrap();
// Phase two fails, so nothing it wrote survives.
let mut t = database.pool.begin().await.unwrap();
Receipt::clear_pending_plaintext(&mut t, "receipt")
.await
.unwrap();
t.rollback().await.unwrap();
// The redelivery is not a fresh claim, but it can resume without
// decrypting the message a second time.
let mut t = database.pool.begin().await.unwrap();
assert!(!Receipt::claim_received(&mut t, "receipt").await.unwrap());
assert_eq!(
Receipt::pending_plaintext(&mut t, "receipt").await.unwrap(),
Some(b"decrypted".to_vec())
);
// Once handling commits, the plaintext is gone and a later redelivery is
// recognised as the plain duplicate it is.
Receipt::clear_pending_plaintext(&mut t, "receipt")
.await
.unwrap();
t.commit().await.unwrap();
let mut t = database.pool.begin().await.unwrap();
assert!(!Receipt::claim_received(&mut t, "receipt").await.unwrap());
assert_eq!(
Receipt::pending_plaintext(&mut t, "receipt").await.unwrap(),
None
);
}
}

View file

@ -78,6 +78,13 @@ pub enum TwonlyError {
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")] #[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
MediaTooLarge { bytes: i64, limit: i64 }, MediaTooLarge { bytes: i64, limit: i64 },
/// Content that will never be processable: it is malformed, or it uses a
/// feature this client does not implement. Callers must not retry it —
/// a reliable-mailbox row carrying such a message is acknowledged so the
/// server stops redelivering it forever.
#[error("unprocessable content: {0}")]
UnprocessableContent(String),
#[error("{0}")] #[error("{0}")]
Generic(String), Generic(String),

View file

@ -110,6 +110,115 @@ impl<'a> MakeWriter<'a> for AppLogWriter {
} }
} }
/// Android sends a native library's stdout to `/dev/null`; only `liblog`
/// reaches logcat, so the console layer has to go through it there.
#[cfg(target_os = "android")]
mod logcat {
use std::ffi::CString;
use std::os::raw::{c_char, c_int};
use tracing::Metadata;
use tracing_subscriber::fmt::MakeWriter;
#[link(name = "log")]
extern "C" {
fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int;
}
const TAG: &[u8] = b"twonly\0";
/// logcat truncates a record at ~4 KiB, so split before it does.
const MAX_PAYLOAD: usize = 3800;
const PRIO_VERBOSE: c_int = 2;
const PRIO_DEBUG: c_int = 3;
const PRIO_INFO: c_int = 4;
const PRIO_WARN: c_int = 5;
const PRIO_ERROR: c_int = 6;
#[derive(Clone, Copy)]
pub(super) struct LogcatWriter;
pub(super) struct LogcatBuffer {
priority: c_int,
buffer: Vec<u8>,
}
impl LogcatBuffer {
const fn new(priority: c_int) -> Self {
Self {
priority,
buffer: Vec::new(),
}
}
fn emit(&self, message: &str) {
let Ok(text) = CString::new(message) else {
return;
};
// SAFETY: both pointers are NUL-terminated and outlive the call.
unsafe {
__android_log_write(self.priority, TAG.as_ptr().cast::<c_char>(), text.as_ptr());
}
}
}
impl std::io::Write for LogcatBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buffer.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Drop for LogcatBuffer {
fn drop(&mut self) {
if self.buffer.is_empty() {
return;
}
let text = String::from_utf8_lossy(&self.buffer);
for line in text.lines() {
// An interior NUL would silently cut the record short.
let line = line.replace('\0', "");
let mut rest = line.as_str();
while !rest.is_empty() {
let split = if rest.len() <= MAX_PAYLOAD {
rest.len()
} else {
let mut index = MAX_PAYLOAD;
while index > 0 && !rest.is_char_boundary(index) {
index -= 1;
}
index.max(1)
};
let (chunk, remainder) = rest.split_at(split);
self.emit(chunk);
rest = remainder;
}
}
}
}
impl<'a> MakeWriter<'a> for LogcatWriter {
type Writer = LogcatBuffer;
fn make_writer(&'a self) -> Self::Writer {
LogcatBuffer::new(PRIO_INFO)
}
fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Self::Writer {
LogcatBuffer::new(match *meta.level() {
tracing::Level::TRACE => PRIO_VERBOSE,
tracing::Level::DEBUG => PRIO_DEBUG,
tracing::Level::INFO => PRIO_INFO,
tracing::Level::WARN => PRIO_WARN,
tracing::Level::ERROR => PRIO_ERROR,
})
}
}
}
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct PlainFields; pub struct PlainFields;
@ -300,11 +409,19 @@ pub(crate) fn init_tracing(data_dir: &Path, in_background: bool) {
} }
TRACING_INIT.get_or_init(|| { TRACING_INIT.get_or_init(|| {
let stdout_layer = Layer::new() #[cfg(not(target_os = "android"))]
let console_layer = Layer::new()
.with_writer(std::io::stdout) .with_writer(std::io::stdout)
.with_ansi(false) .with_ansi(false)
.event_format(ShortEventFormatter::ansi()); .event_format(ShortEventFormatter::ansi());
#[cfg(target_os = "android")]
let console_layer = Layer::new()
.with_writer(logcat::LogcatWriter)
.with_ansi(false)
.fmt_fields(PlainFields)
.event_format(ShortEventFormatter::plain());
let default_filter = if std::env::var("FLUTTER_TEST").is_ok() { let default_filter = if std::env::var("FLUTTER_TEST").is_ok() {
"info,refinery_core=warn,refinery=warn" "info,refinery_core=warn,refinery=warn"
} else { } else {
@ -322,7 +439,7 @@ pub(crate) fn init_tracing(data_dir: &Path, in_background: bool) {
EnvFilter::try_from_default_env() EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(default_filter)), .unwrap_or_else(|_| EnvFilter::new(default_filter)),
) )
.with(stdout_layer) .with(console_layer)
.with(file_layer) .with(file_layer)
.try_init(); .try_init();
}); });

View file

@ -61,7 +61,6 @@ fn report_progress(media_id: &str, percent: i64) {
.bind(&media_id) .bind(&media_id)
.execute(&database.pool) .execute(&database.pool)
.await; .await;
database.notify_committed(["media_files"]);
}); });
} }

View file

@ -52,7 +52,6 @@ impl ContactService {
.insert_on_conflict_update(&mut transaction) .insert_on_conflict_update(&mut transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["contacts"]);
self.send_contact_request( self.send_contact_request(
user.user_id, user.user_id,
@ -115,7 +114,6 @@ impl ContactService {
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
release_deferred_receipts(&database, user_id).await?; release_deferred_receipts(&database, user_id).await?;
database.notify_committed(["contacts", "receipts"]);
Ok(()) Ok(())
} }
@ -223,7 +221,6 @@ impl ContactService {
Group::create_direct_chat(&self.ctx, &mut transaction, contact).await?; Group::create_direct_chat(&self.ctx, &mut transaction, contact).await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["contacts", "groups"]);
self.send_contact_request( self.send_contact_request(
contact_id, contact_id,
@ -258,7 +255,6 @@ impl ContactService {
.await?; .await?;
t.commit().await?; t.commit().await?;
db_app.notify_committed(["contacts"]);
self.send_contact_request( self.send_contact_request(
contact_id, contact_id,

View file

@ -416,7 +416,6 @@ impl DirectMediaUploadService {
.await?; .await?;
} }
} }
database.notify_committed(["media_files", "messages", "receipts"]);
Ok(()) Ok(())
} }

View file

@ -151,7 +151,6 @@ impl GroupService {
Group::set_last_flame_sync(&db.pool, &group.group_id, now).await?; Group::set_last_flame_sync(&db.pool, &group.group_id, now).await?;
} }
db.notify_committed(["groups"]);
Ok(()) Ok(())
} }
@ -224,7 +223,6 @@ impl GroupService {
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["receipts", "groups", "group_members"]);
Ok(()) Ok(())
} }
@ -278,7 +276,6 @@ impl GroupService {
} }
Self::history(&mut tr, &group_id, "createdGroup", None, None, None).await?; Self::history(&mut tr, &group_id, "createdGroup", None, None, None).await?;
tr.commit().await?; tr.commit().await?;
db.notify_committed(["groups", "group_members", "group_histories"]);
MessageService::new(&self.ctx) MessageService::new(&self.ctx)
.send_to_group( .send_to_group(
group_id.clone(), group_id.clone(),
@ -317,7 +314,6 @@ impl GroupService {
.apply_fetched_group_state(&mut t, &group_id, server) .apply_fetched_group_state(&mut t, &group_id, server)
.await?; .await?;
t.commit().await?; t.commit().await?;
database.notify_committed(["groups", "group_members", "contacts", "receipts"]);
// `apply_state` may have queued public-key requests. Nothing else // `apply_state` may have queued public-key requests. Nothing else
// flushes them here -- this is not an inbound-message path -- and they // flushes them here -- this is not an inbound-message path -- and they
@ -659,7 +655,6 @@ impl GroupService {
.execute(&mut tr) .execute(&mut tr)
.await?; .await?;
tr.commit().await?; tr.commit().await?;
db.notify_committed(["groups", "group_histories"]);
Ok(true) Ok(true)
} }
@ -687,7 +682,6 @@ impl GroupService {
.insert_on_conflict_update(&mut tr) .insert_on_conflict_update(&mut tr)
.await?; .await?;
tr.commit().await?; tr.commit().await?;
database.notify_committed(["contacts"]);
Ok(true) Ok(true)
} }
@ -722,7 +716,6 @@ impl GroupService {
self.broadcast_group_public_key(&mut transaction, group_id) self.broadcast_group_public_key(&mut transaction, group_id)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["receipts"]);
Ok(()) Ok(())
} }

View file

@ -138,7 +138,6 @@ impl MediaUploadService {
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["media_files"]);
Ok(media_id) Ok(media_id)
} }
@ -199,7 +198,6 @@ impl MediaUploadService {
Group::record_media_exchange(&mut transaction, group_id, false, now).await?; Group::record_media_exchange(&mut transaction, group_id, false, now).await?;
} }
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["messages", "media_files", "groups", "contacts"]);
drop(database); drop(database);
// Preparation compresses and encrypts, which is far too slow to keep the // Preparation compresses and encrypts, which is far too slow to keep the
@ -375,7 +373,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files", "receipts"]);
drop(database); drop(database);
// The legacy pre-built upload request would pin the old recipient set. // The legacy pre-built upload request would pin the old recipient set.
@ -501,7 +498,6 @@ impl MediaUploadService {
.bind(&receipt.receipt_id) .bind(&receipt.receipt_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["messages", "receipts"]);
return Ok(()); return Ok(());
}; };
drop(database); drop(database);
@ -573,7 +569,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -669,7 +664,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files", "messages"]);
Ok(()) Ok(())
} }
@ -689,7 +683,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files", "messages"]);
MediaFileService::new(&self.ctx).remove_files(media_id, &media.media_type)?; MediaFileService::new(&self.ctx).remove_files(media_id, &media.media_type)?;
tracing::warn!( tracing::warn!(
media_id, media_id,
@ -865,7 +858,6 @@ impl MediaUploadService {
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
} }
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -877,7 +869,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -891,7 +882,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -920,7 +910,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
return Ok(()); return Ok(());
} }
@ -935,7 +924,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
return Ok(()); return Ok(());
} }
@ -950,7 +938,6 @@ impl MediaUploadService {
.bind(media_id) .bind(media_id)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -961,7 +948,6 @@ impl MediaUploadService {
let mut transaction = database.pool.begin().await?; let mut transaction = database.pool.begin().await?;
MediaFile::mark_uploaded(&mut transaction, media_id).await?; MediaFile::mark_uploaded(&mut transaction, media_id).await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["media_files", "messages", "receipts"]);
Ok(()) Ok(())
} }

View file

@ -88,7 +88,6 @@ impl MediaFileService {
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
} }
result result
} }
@ -139,7 +138,6 @@ impl MediaFileService {
if let Some(media_type) = media_type { if let Some(media_type) = media_type {
self.remove_files(media_id, &media_type)?; self.remove_files(media_id, &media_type)?;
} }
database.notify_committed(["media_files"]);
return Ok(()); return Ok(());
} }
if messages.len() != 1 { if messages.len() != 1 {
@ -171,7 +169,6 @@ impl MediaFileService {
if let Some(media_type) = media_type { if let Some(media_type) = media_type {
self.remove_files(media_id, &media_type)?; self.remove_files(media_id, &media_type)?;
} }
database.notify_committed(["messages", "media_files"]);
return Ok(()); return Ok(());
} }
@ -226,7 +223,6 @@ impl MediaFileService {
self.request_reupload(media_id).await?; self.request_reupload(media_id).await?;
return Ok(()); return Ok(());
} }
database.notify_committed(["media_files"]);
Ok(()) Ok(())
} }
@ -301,7 +297,6 @@ impl MediaFileService {
.fetch_all(&database.pool) .fetch_all(&database.pool)
.await?; .await?;
database.notify_committed(["media_files"]);
drop(database); drop(database);
for target in targets { for target in targets {

View file

@ -41,7 +41,6 @@ impl MessageService {
{ {
sqlx::query!("UPDATE groups SET last_message_exchange = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ?", group_id) sqlx::query!("UPDATE groups SET last_message_exchange = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ?", group_id)
.execute(&database.pool).await?; .execute(&database.pool).await?;
database.notify_committed(["groups"]);
} }
let members = sqlx::query_scalar!( let members = sqlx::query_scalar!(
r#"SELECT contact_id FROM group_members r#"SELECT contact_id FROM group_members
@ -168,6 +167,16 @@ impl MessageService {
Ok(()) Ok(())
} }
/// Persists an outgoing text message and hands the delivery off.
///
/// The composer is blocked on this call, and the chat list can only render
/// the new bubble once its own `SELECT` gets the single SQLite connection
/// back. Everything the send needs -- decorating the payload, queueing a
/// receipt per member, establishing a session, the server round trip --
/// competes for exactly that connection, which is how a message could reach
/// the other side before showing up here. So the row the UI renders from is
/// committed and published on its own, in one transaction, and the send
/// runs on a separate task.
pub async fn insert_and_send_text( pub async fn insert_and_send_text(
&self, &self,
group_id: String, group_id: String,
@ -177,13 +186,14 @@ impl MessageService {
let database = self.ctx.app_db.read().await.clone(); let database = self.ctx.app_db.read().await.clone();
let message_id = uuid::Uuid::new_v4().to_string(); let message_id = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp_millis(); let timestamp = chrono::Utc::now().timestamp_millis();
let mut t = database.pool.begin().await?;
sqlx::query!( sqlx::query!(
"UPDATE groups SET draft_message = NULL WHERE group_id = ?", "UPDATE groups SET draft_message = NULL WHERE group_id = ?",
group_id group_id
) )
.execute(&database.pool) .execute(&mut *t)
.await?; .await?;
database.notify_committed(["groups"]);
sqlx::query!( sqlx::query!(
r#"INSERT INTO messages(group_id, message_id, type, content, quotes_message_id, created_at) r#"INSERT INTO messages(group_id, message_id, type, content, quotes_message_id, created_at)
VALUES (?, ?, 'text', ?, ?, ?)"#, VALUES (?, ?, 'text', ?, ?, ?)"#,
@ -193,12 +203,13 @@ impl MessageService {
quote_message_id, quote_message_id,
timestamp / 1000, timestamp / 1000,
) )
.execute(&database.pool) .execute(&mut *t)
.await?; .await?;
database.notify_committed(["messages"]); // The commit hook installed on the connection publishes `groups` and
self.send_to_group( // `messages` on its own, so the chat list is already on its way.
group_id, t.commit().await?;
proto::EncryptedContent {
let content = proto::EncryptedContent {
text_message: Some(encrypted_content::TextMessage { text_message: Some(encrypted_content::TextMessage {
sender_message_id: message_id.clone(), sender_message_id: message_id.clone(),
text, text,
@ -207,11 +218,26 @@ impl MessageService {
}), }),
..Default::default() ..Default::default()
} }
.encode_to_vec(), .encode_to_vec();
Some(message_id.clone()),
false, // The message row already carries the "not acknowledged yet" state the
) // bubble shows, so a failure here is reported the same way a failed
.await?; // network send is: the bubble stays unacknowledged until a retry sweep
// gets it through.
let ctx = self.ctx.clone();
let sent_message_id = message_id.clone();
tokio::spawn(async move {
if let Err(error) = Self::new(&ctx)
.send_to_group(group_id, content, Some(sent_message_id.clone()), false)
.await
{
tracing::warn!(
message_id = sent_message_id,
"sending the text message failed: {error}"
);
}
});
Ok(message_id) Ok(message_id)
} }
@ -235,7 +261,6 @@ impl MessageService {
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["messages"]);
self.send_to_group( self.send_to_group(
group_id, group_id,
proto::EncryptedContent { proto::EncryptedContent {
@ -330,7 +355,6 @@ impl MessageService {
.ok_or_else(|| TwonlyError::Generic("contact does not exist".into()))?; .ok_or_else(|| TwonlyError::Generic("contact does not exist".into()))?;
Group::create_direct_chat(&self.ctx, &mut transaction, contact).await?; Group::create_direct_chat(&self.ctx, &mut transaction, contact).await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["groups", "group_members"]);
let data = proto::AdditionalMessageData { let data = proto::AdditionalMessageData {
r#type: proto::additional_message_data::Type::AskAboutUser as i32, r#type: proto::additional_message_data::Type::AskAboutUser as i32,
link: None, link: None,
@ -472,7 +496,6 @@ impl MessageService {
) )
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["messages", "notification_outbox"]);
Ok(()) Ok(())
} }

View file

@ -279,7 +279,7 @@ pub(crate) async fn record_incoming_event(
/// stale rows keeps them from producing an alert or inflating the badge. /// stale rows keeps them from producing an alert or inflating the badge.
async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> { async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> {
let cleared_at = current_time().timestamp(); let cleared_at = current_time().timestamp();
let cleared = sqlx::query( sqlx::query(
r#" r#"
UPDATE notification_outbox UPDATE notification_outbox
SET cleared_at = ? SET cleared_at = ?
@ -296,9 +296,6 @@ async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> {
.bind(cleared_at) .bind(cleared_at)
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
if cleared.rows_affected() != 0 {
database.notify_committed(["notification_outbox"]);
}
Ok(()) Ok(())
} }
@ -481,7 +478,6 @@ pub async fn acknowledge_batch(ctx: &Arc<Context>, event_ids: &[String]) -> Resu
.await?; .await?;
} }
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["notification_outbox"]);
Ok(()) Ok(())
} }
@ -503,7 +499,6 @@ pub async fn clear_conversation(ctx: &Arc<Context>, conversation_id: &str) -> Re
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["notification_outbox"]);
Ok(notification_ids) Ok(notification_ids)
} }
@ -531,9 +526,6 @@ pub async fn clear_contact_requests(ctx: &Arc<Context>) -> Result<Vec<String>> {
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
if !notification_ids.is_empty() {
database.notify_committed(["notification_outbox"]);
}
Ok(notification_ids) Ok(notification_ids)
} }

View file

@ -119,7 +119,6 @@ impl UserDiscovery {
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
} }
database.notify_committed(["user_discovery_announced_users"]);
Ok(()) Ok(())
} }
pub fn new( pub fn new(
@ -195,7 +194,6 @@ impl UserDiscovery {
) )
.await?; .await?;
transaction.commit().await?; transaction.commit().await?;
database.notify_committed(["user_discovery_shares"]);
Ok(()) Ok(())
} }
@ -620,7 +618,11 @@ impl UserDiscovery {
let split_index = shares.len() - (config.threshold - 1) as usize; let split_index = shares.len() - (config.threshold - 1) as usize;
verification_shares.extend(shares.drain(split_index..)); verification_shares.extend(shares.drain(split_index..));
sqlx::query!("DELETE FROM user_discovery_shares") // `WHERE 1` is load-bearing: without a WHERE clause SQLite takes the
// truncate optimization, which drops every row without ever invoking
// the update hook the change stream is built on, so the UI would never
// learn the shares were replaced.
sqlx::query!("DELETE FROM user_discovery_shares WHERE 1")
.execute(&mut **t) .execute(&mut **t)
.await .await
.map_err(|error| TwonlyError::UserDiscoveryStore(error.to_string()))?; .map_err(|error| TwonlyError::UserDiscoveryStore(error.to_string()))?;

View file

@ -71,7 +71,6 @@ impl Tester {
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
} }
database.notify_committed(["key_verifications"]);
Ok(()) Ok(())
} }
@ -629,7 +628,6 @@ impl Tester {
) )
.execute(&database.pool) .execute(&database.pool)
.await?; .await?;
database.notify_committed(["contacts"]);
Ok(()) Ok(())
} }