From cce673123366ffb201340977a394b8bcc5029d04 Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Wed, 19 Aug 2026 09:24:16 -0600 Subject: [PATCH] feat(bindings_mobile): expose the app data change callback and update guard --- bindings/mobile/benches/create_client.rs | 3 + bindings/mobile/src/mls.rs | 28 +++++- bindings/mobile/src/mls/change_callbacks.rs | 91 +++++++++++++++++++ bindings/mobile/src/mls/test_utils.rs | 1 + bindings/mobile/src/mls/tests/client.rs | 6 ++ .../mobile/src/mls/tests/group_management.rs | 2 + bindings/mobile/src/mls/tests/identity.rs | 11 +++ bindings/mobile/src/mls/tests/mod.rs | 2 + bindings/mobile/src/mls/tests/networking.rs | 2 + 9 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 bindings/mobile/src/mls/change_callbacks.rs diff --git a/bindings/mobile/benches/create_client.rs b/bindings/mobile/benches/create_client.rs index 9dd379e0f5..b67dc2c1b9 100644 --- a/bindings/mobile/benches/create_client.rs +++ b/bindings/mobile/benches/create_client.rs @@ -74,6 +74,7 @@ fn create_ffi_client(c: &mut Criterion) { None, None, None, + None, ) .instrument(span) .await @@ -119,6 +120,7 @@ fn cached_create_ffi_client(c: &mut Criterion) { None, None, None, + None, ) .await .unwrap(); @@ -158,6 +160,7 @@ fn cached_create_ffi_client(c: &mut Criterion) { None, None, None, + None, ) .instrument(span) .await diff --git a/bindings/mobile/src/mls.rs b/bindings/mobile/src/mls.rs index 6c26e47983..a761f58912 100644 --- a/bindings/mobile/src/mls.rs +++ b/bindings/mobile/src/mls.rs @@ -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"))] @@ -393,8 +394,17 @@ impl DbOptions { /// /// xmtp.create_client(account_identifier, nonce, inbox_id, Option) /// ``` +/// +/// `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, @@ -407,6 +417,7 @@ pub async fn create_client( allow_offline: Option, fork_recovery_opts: Option, worker_config: Option, + change_callbacks: Option, ) -> Result, FfiError> { let ident = account_identifier.clone(); init_logger(); @@ -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!( @@ -1717,6 +1732,13 @@ impl TryFrom 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, } #[derive(uniffi::Enum, Debug)] @@ -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(()) } diff --git a/bindings/mobile/src/mls/change_callbacks.rs b/bindings/mobile/src/mls/change_callbacks.rs new file mode 100644 index 0000000000..c549b0e72f --- /dev/null +++ b/bindings/mobile/src/mls/change_callbacks.rs @@ -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, + /// Value before the change. `None` when nothing was set. + pub old_value: Option, + /// Value after the change. `None` when the field was cleared. + pub new_value: Option, +} + +impl From 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>, +} + +impl From 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, +} + +impl FfiAppDataChangeCallbackBridge { + pub fn new(callback: Arc) -> 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; + } +} diff --git a/bindings/mobile/src/mls/test_utils.rs b/bindings/mobile/src/mls/test_utils.rs index 2fd96fc73f..d8d1f020a2 100644 --- a/bindings/mobile/src/mls/test_utils.rs +++ b/bindings/mobile/src/mls/test_utils.rs @@ -167,6 +167,7 @@ where None, None, None, + None, ) .await .unwrap(); diff --git a/bindings/mobile/src/mls/tests/client.rs b/bindings/mobile/src/mls/tests/client.rs index a58e161200..849f0295bb 100644 --- a/bindings/mobile/src/mls/tests/client.rs +++ b/bindings/mobile/src/mls/tests/client.rs @@ -24,6 +24,7 @@ async fn test_create_client_with_storage() { None, None, None, + None, ) .await .unwrap(); @@ -43,6 +44,7 @@ async fn test_create_client_with_storage() { None, None, None, + None, ) .await .unwrap(); @@ -78,6 +80,7 @@ async fn test_create_client_with_key() { None, None, None, + None, ) .await .unwrap(); @@ -104,6 +107,7 @@ async fn test_create_client_with_key() { None, None, None, + None, ) .await .is_err(); @@ -134,6 +138,7 @@ async fn test_can_message() { None, None, None, + None, ) .await .unwrap(); @@ -174,6 +179,7 @@ async fn test_can_message() { None, None, None, + None, ) .await .unwrap(); diff --git a/bindings/mobile/src/mls/tests/group_management.rs b/bindings/mobile/src/mls/tests/group_management.rs index f37d323737..8a9ef07ecd 100644 --- a/bindings/mobile/src/mls/tests/group_management.rs +++ b/bindings/mobile/src/mls/tests/group_management.rs @@ -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(); @@ -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(); diff --git a/bindings/mobile/src/mls/tests/identity.rs b/bindings/mobile/src/mls/tests/identity.rs index b435e1a98c..1a4ec2dd64 100644 --- a/bindings/mobile/src/mls/tests/identity.rs +++ b/bindings/mobile/src/mls/tests/identity.rs @@ -40,6 +40,7 @@ async fn test_can_add_wallet_to_inbox() { None, None, None, + None, ) .await .unwrap(); @@ -139,6 +140,7 @@ async fn test_can_revoke_wallet() { None, None, None, + None, ) .await .unwrap(); @@ -236,6 +238,7 @@ async fn test_invalid_external_signature() { None, None, None, + None, ) .await .unwrap(); @@ -435,6 +438,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() { None, None, None, + None, ) .await .unwrap(); @@ -480,6 +484,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() { None, None, None, + None, ) .await .unwrap(); @@ -569,6 +574,7 @@ async fn test_can_not_create_new_inbox_id_with_already_associated_wallet() { None, None, None, + None, ) .await; @@ -609,6 +615,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association() None, None, None, + None, ) .await .unwrap(); @@ -637,6 +644,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association() None, None, None, + None, ) .await .unwrap(); @@ -662,6 +670,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association() None, None, None, + None, ) .await .unwrap(); @@ -698,6 +707,7 @@ async fn test_wallet_b_cannot_create_new_client_for_inbox_b_after_association() None, None, None, + None, ) .await; @@ -810,6 +820,7 @@ async fn test_sorts_members_by_created_at_using_ffi_identifiers() { None, None, None, + None, ) .await .unwrap(); diff --git a/bindings/mobile/src/mls/tests/mod.rs b/bindings/mobile/src/mls/tests/mod.rs index cf84c16407..52434d0eb2 100644 --- a/bindings/mobile/src/mls/tests/mod.rs +++ b/bindings/mobile/src/mls/tests/mod.rs @@ -361,6 +361,7 @@ pub(crate) async fn new_test_client_with_wallet_and_history_sync_url( None, None, None, + None, ) .await .unwrap(); @@ -398,6 +399,7 @@ pub(crate) async fn new_test_client_no_panic( None, None, None, + None, ) .await?; diff --git a/bindings/mobile/src/mls/tests/networking.rs b/bindings/mobile/src/mls/tests/networking.rs index b75eb37358..02c2848d35 100644 --- a/bindings/mobile/src/mls/tests/networking.rs +++ b/bindings/mobile/src/mls/tests/networking.rs @@ -75,6 +75,7 @@ async fn create_client_does_not_hit_network() { None, None, None, + None, ) .await .unwrap(); @@ -118,6 +119,7 @@ async fn create_client_does_not_hit_network() { Some(true), None, None, + None, ) .await .unwrap();