diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index 6f919f71e..b5a002ae9 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -439,6 +439,12 @@ class Account: """Wait for reaction change event.""" return self.wait_for_event(EventType.REACTIONS_CHANGED) + def wait_for_imap_inbox_idle(self): + """Wait until all messages are fetched, + and the IMAP loop enters IDLE mode. + """ + self.wait_for_event(EventType.IMAP_INBOX_IDLE) + def get_fresh_messages_in_arrival_order(self) -> list[Message]: """Return fresh messages list sorted in the order of their arrival, with ascending IDs.""" warn( diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index 31f483520..4d557bd69 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -112,6 +112,98 @@ def test_qr_securejoin(acfactory, protect): fiona.wait_for_securejoin_joiner_success() +@pytest.mark.parametrize("all_devices_online", [True, False]) +def test_qr_securejoin_broadcast(acfactory, all_devices_online): + alice, bob, fiona = acfactory.get_online_accounts(3) + + alice2 = alice.clone() + bob2 = bob.clone() + + if all_devices_online: + alice2.start_io() + bob2.start_io() + + logging.info("Alice creates a broadcast") + alice_chat = alice.create_broadcast("Broadcast channel for everyone!") + + logging.info("Bob joins the broadcast") + + qr_code = alice_chat.get_qr_code() + bob.secure_join(qr_code) + alice.wait_for_securejoin_inviter_success() + bob.wait_for_securejoin_joiner_success() + + snapshot = bob.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == f"Member Me added by {alice.get_config('addr')}." + + alice_chat.send_text("Hello everyone!") + snapshot = bob.wait_for_incoming_msg().get_snapshot() + assert snapshot.text == "Hello everyone!" + + def check_account(ac, contact, inviter_side, please_wait_info_msg=False): + # Check that the chat partner is verified. + contact_snapshot = contact.get_snapshot() + assert contact_snapshot.is_verified + + chat = ac.get_chatlist()[0] + chat_msgs = chat.get_messages() + + if please_wait_info_msg: + first_msg = chat_msgs.pop(0).get_snapshot() + assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…" + assert first_msg.is_info + + encrypted_msg = chat_msgs[0].get_snapshot() + assert encrypted_msg.text == "Messages are end-to-end encrypted." + assert encrypted_msg.is_info + + member_added_msg = chat_msgs[1].get_snapshot() + if inviter_side: + assert member_added_msg.text == f"Member {contact_snapshot.display_name} added." + else: + assert member_added_msg.text == f"Member Me added by {contact_snapshot.display_name}." + assert member_added_msg.is_info + + hello_msg = chat_msgs[2].get_snapshot() + assert hello_msg.text == "Hello everyone!" + assert not hello_msg.is_info + + assert len(chat_msgs) == 3 + + chat_snapshot = chat.get_basic_snapshot() # TODO or get_full_snapshot() + assert chat_snapshot.is_encrypted + + # TODO check more things + + check_account(alice, alice.create_contact(bob), inviter_side=True) + check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True) + + logging.info("===================== Test Alice's second device =====================") + + # Start second Alice device, if it wasn't started already. + alice2.start_io() + alice2.wait_for_securejoin_inviter_success() + alice2.wait_for_imap_inbox_idle() + check_account(alice2, alice2.create_contact(bob), inviter_side=True) + + logging.info("===================== Test Bob's second device =====================") + + # Start second Bob device, if it wasn't started already. + bob2.start_io() + bob2.wait_for_securejoin_joiner_success() + bob2.wait_for_imap_inbox_idle() + check_account(bob2, bob2.create_contact(alice), inviter_side=False) + + # The QR code token is synced, so alice2 must be able to handle join requests. + logging.info("Fiona joins the group via alice2") + alice.stop_io() + fiona.secure_join(qr_code) + alice2.wait_for_securejoin_inviter_success() + fiona.wait_for_securejoin_joiner_success() + + # TODO test that Fiona is in the channel correctly + + def test_qr_securejoin_contact_request(acfactory) -> None: """Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode.""" alice, bob = acfactory.get_online_accounts(2) diff --git a/deltachat-rpc-client/tox.ini b/deltachat-rpc-client/tox.ini index 2ad52b8f5..42da40ba2 100644 --- a/deltachat-rpc-client/tox.ini +++ b/deltachat-rpc-client/tox.ini @@ -6,7 +6,7 @@ envlist = [testenv] commands = - pytest -n6 {posargs} + pytest {posargs} setenv = # Avoid stack overflow when Rust core is built without optimizations. RUST_MIN_STACK=8388608 @@ -25,6 +25,6 @@ commands = ruff check src/ examples/ tests/ [pytest] -timeout = 300 +timeout = 30 log_cli = true log_level = debug diff --git a/src/chat.rs b/src/chat.rs index 4e7382be1..6fc97488f 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -601,11 +601,23 @@ impl ChatId { || chat.is_device_talk() || chat.is_self_talk() || (!chat.can_send(context).await? && !chat.is_contact_request()) + // For chattype InBrodacast, the info message is added when the member-added message is received + // by directly calling add_encrypted_msg(): + || chat.typ == Chattype::InBroadcast || chat.blocked == Blocked::Yes { return Ok(()); } + self.add_encrypted_msg(context, timestamp_sort).await?; + Ok(()) + } + + pub(crate) async fn add_encrypted_msg( + self, + context: &Context, + timestamp_sort: i64, + ) -> Result<()> { let text = stock_str::messages_e2e_encrypted(context).await; add_info_msg_with_cmd( context, @@ -3817,10 +3829,17 @@ pub(crate) async fn create_broadcast_ex( chat_name: String, secret: String, ) -> Result { - let row_id = { + let chat_name = sanitize_single_line(&chat_name); + if chat_name.is_empty() { + bail!("Invalid broadcast channel name: {chat_name}."); + } + + let timestamp = create_smeared_timestamp(context); + let chat_id = { let chat_name = &chat_name; let grpid = &grpid; - let trans_fn = |t: &mut rusqlite::Transaction| { + let trans_fn = |t: &mut rusqlite::Transaction| -> Result { + // TODO it's not needed to lookup an existing broadcast here let cnt = t.execute("UPDATE chats SET name=? WHERE grpid=?", (chat_name, grpid))?; ensure!(cnt <= 1, "{cnt} chats exist with grpid {grpid}"); if cnt == 1 { @@ -3828,7 +3847,7 @@ pub(crate) async fn create_broadcast_ex( "SELECT id FROM chats WHERE grpid=? AND type=?", (grpid, Chattype::OutBroadcast), |row| { - let id: isize = row.get(0)?; + let id: u32 = row.get(0)?; Ok(id) }, )?); @@ -3837,23 +3856,20 @@ pub(crate) async fn create_broadcast_ex( "INSERT INTO chats \ (type, name, grpid, created_timestamp) \ VALUES(?, ?, ?, ?);", - ( - Chattype::OutBroadcast, - &chat_name, - &grpid, - create_smeared_timestamp(context), - ), + (Chattype::OutBroadcast, &chat_name, &grpid, timestamp), )?; let chat_id = t.last_insert_rowid(); t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?; - Ok(t.last_insert_rowid().try_into()?) + Ok(chat_id.try_into()?) }; context.sql.transaction(trans_fn).await? }; - let chat_id = ChatId::new(u32::try_from(row_id)?); + let chat_id = ChatId::new(chat_id); + chat_id.maybe_add_encrypted_msg(context, timestamp).await?; context.emit_msgs_changed_without_ids(); chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, chat_id); if sync.into() { let id = SyncId::Grpid(grpid); diff --git a/src/receive_imf.rs b/src/receive_imf.rs index cf330171a..2d15bda90 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3571,6 +3571,9 @@ async fn apply_in_broadcast_changes( info!(context, "No-op broadcast 'Member added' message (TRASH)"); msg = "".to_string(); } else { + chat.id + .add_encrypted_msg(context, mime_parser.timestamp_sent) + .await?; msg = stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await; }