Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bindings/mobile/benches/create_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn create_ffi_client(c: &mut Criterion) {
None,
None,
None,
None,
)
.instrument(span)
.await
Expand Down Expand Up @@ -119,6 +120,7 @@ fn cached_create_ffi_client(c: &mut Criterion) {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -158,6 +160,7 @@ fn cached_create_ffi_client(c: &mut Criterion) {
None,
None,
None,
None,
)
.instrument(span)
.await
Expand Down
28 changes: 26 additions & 2 deletions bindings/mobile/src/mls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ pub use crate::message::{
FfiRemoteAttachment, FfiTransactionReference,
};

pub mod change_callbacks;
pub mod device_sync;
pub mod gateway_auth;
#[cfg(any(test, feature = "bench"))]
Expand Down Expand Up @@ -393,8 +394,17 @@ impl DbOptions {
///
/// xmtp.create_client(account_identifier, nonce, inbox_id, Option<legacy_signed_private_key_proto>)
/// ```
///
/// `change_callbacks` is unstable: notifications for group-state changes,
/// registered here because the changes they report arrive from the stream and
/// sync paths, where no SDK call is on the stack to carry them. `None` (the
/// SDK-side default) registers nothing. See
/// [`change_callbacks::FfiUnstableChangeCallbacks`].
#[allow(clippy::too_many_arguments)]
#[uniffi::export(async_runtime = "tokio")]
// `change_callbacks` is defaulted so adding it leaves the generated
// Swift/Kotlin signature unchanged for callers that register nothing — the
// same additive-by-default rule the options records follow.
#[uniffi::export(async_runtime = "tokio", default(change_callbacks = None))]
#[tracing::instrument(level = "debug", skip_all)]
pub async fn create_client(
api: Arc<XmtpApiClient>,
Expand All @@ -407,6 +417,7 @@ pub async fn create_client(
allow_offline: Option<bool>,
fork_recovery_opts: Option<FfiForkRecoveryOpts>,
worker_config: Option<FfiWorkerConfig>,
change_callbacks: Option<change_callbacks::FfiUnstableChangeCallbacks>,
) -> Result<Arc<FfiXmtpClient>, FfiError> {
let ident = account_identifier.clone();
init_logger();
Expand Down Expand Up @@ -499,6 +510,10 @@ pub async fn create_client(
builder = builder.worker_config(worker_config.into());
}

if let Some(change_callbacks) = change_callbacks {
builder = builder.unstable_change_callbacks(change_callbacks.into());
}

let xmtp_client = builder.default_mls_store()?.build().await?;

log::info!(
Expand Down Expand Up @@ -1717,6 +1732,13 @@ impl TryFrom<FfiPermissionPolicySet> for PolicySet {
pub struct FfiUpdateAppDataOptions {
/// The new value for the group's opaque `APP_DATA` string slot.
pub value: String,
/// Optional compare-and-swap guard. When set, the update is abandoned
/// with an `AppDataSuperseded` error — rather than overwriting — if the
/// committed value is no longer this, including when another member's
/// commit wins the race after this update was published. Leave unset for
/// the historical last-writer-wins behavior.
#[uniffi(default = None)]
pub expected_value: Option<String>,
}

#[derive(uniffi::Enum, Debug)]
Expand Down Expand Up @@ -2987,7 +3009,9 @@ impl FfiConversation {

#[tracing::instrument(level = "debug", skip_all)]
pub async fn update_app_data(&self, options: FfiUpdateAppDataOptions) -> Result<(), FfiError> {
self.inner.update_app_data(options.value, None).await?;
self.inner
.update_app_data(options.value, options.expected_value)
.await?;
Ok(())
}

Expand Down
91 changes: 91 additions & 0 deletions bindings/mobile/src/mls/change_callbacks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Unstable: FFI mirror of [`xmtp_mls::groups::change_callbacks`].
//!
//! Registered once, at [`crate::mls::create_client`]. See the core module for
//! the delivery contract; the short version is that a callback fires after the
//! commit is durable and the group lock is released, so it may publish the
//! result of its merge from inside the callback.

use std::sync::Arc;
use xmtp_mls::groups::change_callbacks::{
AppDataChange, AppDataChangeCallback, UnstableChangeCallbacks,
};

/// A change to a group's opaque `app_data`, as observed after it was applied.
#[derive(uniffi::Record, Clone, Debug)]
pub struct FfiAppDataChange {
/// The group whose `app_data` changed.
pub group_id: Vec<u8>,
/// Value before the change. `None` when nothing was set.
pub old_value: Option<String>,
/// Value after the change. `None` when the field was cleared.
pub new_value: Option<String>,
}

impl From<AppDataChange> for FfiAppDataChange {
fn from(change: AppDataChange) -> Self {
Self {
group_id: change.group_id,
old_value: change.old_value,
new_value: change.new_value,
}
}
}

/// Notified when a processed message changed a group's `app_data`.
///
/// Async so an implementation can complete a semantic merge — including
/// republishing the merged value via `update_app_data` — before returning.
/// Fires for local commits as well as remote ones, so the merge must be
/// idempotent.
#[uniffi::export(with_foreign)]
#[xmtp_common::async_trait]
pub trait FfiAppDataChangeCallback: Send + Sync + 'static {
async fn on_app_data_changed(&self, change: FfiAppDataChange);
}

/// Unstable: the set of group-change callbacks to register on a client.
///
/// Only `app_data` exists today. This is a record rather than a bare callback
/// argument so callbacks for the other mutable fields (name, description,
/// image url, admin lists, permissions, disappearing settings) can be added as
/// fields later — same pattern as [`crate::mls::FfiUpdateAppDataOptions`].
///
/// WARNING: uniffi Records get NO default field values unless the field
/// carries `#[uniffi(default = ...)]`. Any field added later MUST carry a
/// uniffi default (and a serde/napi default on the wasm/node mirror), or the
/// generated Swift/Kotlin constructors change and the addition breaks compiled
/// apps.
#[derive(uniffi::Record, Clone, Default)]
pub struct FfiUnstableChangeCallbacks {
#[uniffi(default = None)]
pub app_data: Option<Arc<dyn FfiAppDataChangeCallback>>,
}

impl From<FfiUnstableChangeCallbacks> for UnstableChangeCallbacks {
fn from(callbacks: FfiUnstableChangeCallbacks) -> Self {
Self {
app_data: callbacks
.app_data
.map(|cb| Arc::new(FfiAppDataChangeCallbackBridge::new(cb)) as _),
}
}
}

/// Adapts the foreign-implemented [`FfiAppDataChangeCallback`] to the core
/// trait, mirroring `FfiAuthCallbackBridge`.
pub(crate) struct FfiAppDataChangeCallbackBridge {
callback: Arc<dyn FfiAppDataChangeCallback>,
}

impl FfiAppDataChangeCallbackBridge {
pub fn new(callback: Arc<dyn FfiAppDataChangeCallback>) -> Self {
Self { callback }
}
}

#[xmtp_common::async_trait]
impl AppDataChangeCallback for FfiAppDataChangeCallbackBridge {
async fn on_app_data_changed(&self, change: AppDataChange) {
self.callback.on_app_data_changed(change.into()).await;
}
}
1 change: 1 addition & 0 deletions bindings/mobile/src/mls/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ where
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down
6 changes: 6 additions & 0 deletions bindings/mobile/src/mls/tests/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ async fn test_create_client_with_storage() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand All @@ -43,6 +44,7 @@ async fn test_create_client_with_storage() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -78,6 +80,7 @@ async fn test_create_client_with_key() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand All @@ -104,6 +107,7 @@ async fn test_create_client_with_key() {
None,
None,
None,
None,
)
.await
.is_err();
Expand Down Expand Up @@ -134,6 +138,7 @@ async fn test_can_message() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -174,6 +179,7 @@ async fn test_can_message() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down
2 changes: 2 additions & 0 deletions bindings/mobile/src/mls/tests/group_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ async fn test_app_data_permission_update() {
.conversation
.update_app_data(FfiUpdateAppDataOptions {
value: "bola's data".to_string(),
expected_value: None,
})
.await
.unwrap_err();
Expand Down Expand Up @@ -369,6 +370,7 @@ async fn test_app_data_permission_update() {
.conversation
.update_app_data(FfiUpdateAppDataOptions {
value: "bola's data".to_string(),
expected_value: None,
})
.await
.unwrap();
Expand Down
11 changes: 11 additions & 0 deletions bindings/mobile/src/mls/tests/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async fn test_can_add_wallet_to_inbox() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -139,6 +140,7 @@ async fn test_can_revoke_wallet() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -236,6 +238,7 @@ async fn test_invalid_external_signature() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -435,6 +438,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -480,6 +484,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -569,6 +574,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() {
None,
None,
None,
None,
)
.await;

Expand Down Expand Up @@ -609,6 +615,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association()
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -637,6 +644,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association()
None,
None,
None,
None,
)
.await
.unwrap();
Expand All @@ -662,6 +670,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association()
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -698,6 +707,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association()
None,
None,
None,
None,
)
.await;

Expand Down Expand Up @@ -810,6 +820,7 @@ async fn test_sorts_members_by_created_at_using_ffi_identifiers() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down
2 changes: 2 additions & 0 deletions bindings/mobile/src/mls/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ pub(crate) async fn new_test_client_with_wallet_and_history_sync_url(
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -398,6 +399,7 @@ pub(crate) async fn new_test_client_no_panic(
None,
None,
None,
None,
)
.await?;

Expand Down
2 changes: 2 additions & 0 deletions bindings/mobile/src/mls/tests/networking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ async fn create_client_does_not_hit_network() {
None,
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -118,6 +119,7 @@ async fn create_client_does_not_hit_network() {
Some(true),
None,
None,
None,
)
.await
.unwrap();
Expand Down
Loading