use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use nostr_double_ratchet::{
AppKeys, CreateGroupOptions, DeviceEntry, FileStorageAdapter, GroupData, GroupDecryptedEvent,
GroupSendEvent, InMemoryStorage, Invite, NdrRuntime, Session, SessionManagerEvent,
SessionState, StorageAdapter,
};
mod error;
pub use error::NdrError;
#[uniffi::export]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[derive(uniffi::Record)]
pub struct FfiKeyPair {
pub public_key_hex: String,
pub private_key_hex: String,
}
#[derive(uniffi::Record)]
pub struct InviteAcceptResult {
pub session: Arc<SessionHandle>,
pub response_event_json: String,
}
#[derive(uniffi::Record)]
pub struct SendResult {
pub outer_event_json: String,
pub inner_event_json: String,
}
#[derive(uniffi::Record)]
pub struct DecryptResult {
pub plaintext: String,
pub inner_event_json: String,
}
#[derive(uniffi::Record)]
pub struct PubSubEvent {
pub kind: String,
pub subid: Option<String>,
pub filter_json: Option<String>,
pub event_json: Option<String>,
pub sender_pubkey_hex: Option<String>,
pub content: Option<String>,
pub event_id: Option<String>,
}
#[derive(uniffi::Record)]
pub struct SessionManagerAcceptInviteResult {
pub owner_pubkey_hex: String,
pub inviter_device_pubkey_hex: String,
pub device_id: String,
pub created_new_session: bool,
}
#[derive(uniffi::Record)]
pub struct FfiGroupData {
pub id: String,
pub name: String,
pub description: Option<String>,
pub picture: Option<String>,
pub members: Vec<String>,
pub admins: Vec<String>,
pub created_at_ms: u64,
pub secret: Option<String>,
pub accepted: Option<bool>,
}
#[derive(uniffi::Record)]
pub struct GroupSendResult {
pub outer_event_json: String,
pub inner_event_json: String,
pub outer_event_id: String,
pub inner_event_id: String,
}
#[derive(uniffi::Record)]
pub struct GroupCreateFanout {
pub enabled: bool,
pub attempted: u64,
pub succeeded: Vec<String>,
pub failed: Vec<String>,
}
#[derive(uniffi::Record)]
pub struct GroupCreateResult {
pub group: FfiGroupData,
pub metadata_rumor_json: Option<String>,
pub fanout: GroupCreateFanout,
}
#[derive(uniffi::Record)]
pub struct GroupDecryptedResult {
pub group_id: String,
pub sender_event_pubkey_hex: String,
pub sender_device_pubkey_hex: String,
pub sender_owner_pubkey_hex: Option<String>,
pub outer_event_id: String,
pub outer_created_at: u64,
pub key_id: u32,
pub message_number: u32,
pub inner_event_json: String,
pub inner_event_id: String,
}
#[derive(uniffi::Record)]
pub struct GroupOuterSubscriptionPlanResult {
pub authors: Vec<String>,
pub added_authors: Vec<String>,
}
#[derive(uniffi::Record)]
pub struct MessagePushSessionStateResult {
pub state_json: String,
pub tracked_sender_pubkeys: Vec<String>,
pub has_receiving_capability: bool,
}
#[uniffi::export]
pub fn generate_keypair() -> FfiKeyPair {
let keys = nostr::Keys::generate();
FfiKeyPair {
public_key_hex: keys.public_key().to_hex(),
private_key_hex: keys.secret_key().to_secret_hex(),
}
}
#[uniffi::export]
pub fn derive_public_key(privkey_hex: String) -> Result<String, NdrError> {
let privkey = parse_private_key(&privkey_hex)?;
let secret_key =
nostr::SecretKey::from_slice(&privkey).map_err(|e| NdrError::InvalidKey(e.to_string()))?;
Ok(nostr::Keys::new(secret_key).public_key().to_hex())
}
#[derive(uniffi::Record)]
pub struct FfiDeviceEntry {
pub identity_pubkey_hex: String,
pub created_at: u64,
pub device_label: Option<String>,
pub client_label: Option<String>,
}
fn ffi_device_entry_from_app_keys(app_keys: &AppKeys, device: DeviceEntry) -> FfiDeviceEntry {
let labels = app_keys.get_device_labels(&device.identity_pubkey);
FfiDeviceEntry {
identity_pubkey_hex: hex::encode(device.identity_pubkey.to_bytes()),
created_at: device.created_at,
device_label: labels.and_then(|label| label.device_label.clone()),
client_label: labels.and_then(|label| label.client_label.clone()),
}
}
fn owner_keys_from_privkey_hex(owner_privkey_hex: &str) -> Result<nostr::Keys, NdrError> {
let owner_privkey = parse_private_key(owner_privkey_hex)?;
let owner_sk = nostr::SecretKey::from_slice(&owner_privkey)
.map_err(|e| NdrError::InvalidKey(e.to_string()))?;
Ok(nostr::Keys::new(owner_sk))
}
fn parse_app_keys_for_owner(
event: &nostr::Event,
owner_privkey_hex: Option<&str>,
) -> Result<AppKeys, NdrError> {
match owner_privkey_hex {
Some(privkey_hex) => {
let owner_keys = owner_keys_from_privkey_hex(privkey_hex)?;
Ok(AppKeys::from_event_with_labels(event, &owner_keys)?)
}
None => Ok(AppKeys::from_event(event)?),
}
}
#[uniffi::export]
pub fn create_signed_app_keys_event(
owner_pubkey_hex: String,
owner_privkey_hex: String,
devices: Vec<FfiDeviceEntry>,
) -> Result<String, NdrError> {
let owner_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&owner_pubkey_hex)?;
let owner_keys = owner_keys_from_privkey_hex(&owner_privkey_hex)?;
if owner_keys.public_key() != owner_pubkey {
return Err(NdrError::InvalidKey(
"owner pubkey does not match owner private key".to_string(),
));
}
let entries = devices
.iter()
.filter_map(|d| {
let pk = nostr_double_ratchet::utils::pubkey_from_hex(&d.identity_pubkey_hex).ok()?;
Some(DeviceEntry::new(pk, d.created_at))
})
.collect::<Vec<_>>();
let mut app_keys = AppKeys::new(entries);
for device in devices {
let Ok(identity_pubkey) =
nostr_double_ratchet::utils::pubkey_from_hex(&device.identity_pubkey_hex)
else {
continue;
};
if device.device_label.is_none() && device.client_label.is_none() {
continue;
}
app_keys.set_device_labels(
identity_pubkey,
device.device_label,
device.client_label,
None,
);
}
let unsigned = app_keys.get_encrypted_event(&owner_keys)?;
let signed = unsigned
.sign_with_keys(&owner_keys)
.map_err(|e| NdrError::Serialization(e.to_string()))?;
Ok(serde_json::to_string(&signed)?)
}
#[uniffi::export]
pub fn parse_app_keys_event(
event_json: String,
owner_privkey_hex: Option<String>,
) -> Result<Vec<FfiDeviceEntry>, NdrError> {
let event: nostr::Event = serde_json::from_str(&event_json)?;
let app_keys = parse_app_keys_for_owner(&event, owner_privkey_hex.as_deref())?;
Ok(app_keys
.get_all_devices()
.into_iter()
.map(|d| ffi_device_entry_from_app_keys(&app_keys, d))
.collect())
}
#[uniffi::export]
pub fn resolve_latest_app_keys_devices(
event_jsons: Vec<String>,
owner_privkey_hex: Option<String>,
) -> Result<Vec<FfiDeviceEntry>, NdrError> {
let events = event_jsons
.iter()
.filter_map(|event_json| serde_json::from_str::<nostr::Event>(event_json).ok())
.collect::<Vec<_>>();
let mut latest: Option<nostr_double_ratchet::AppKeysSnapshot> = None;
for event in events.iter() {
if !nostr_double_ratchet::is_app_keys_event(event) {
continue;
}
let Ok(app_keys) = parse_app_keys_for_owner(event, owner_privkey_hex.as_deref()) else {
continue;
};
latest = Some(match latest.as_ref() {
Some(current) => nostr_double_ratchet::apply_app_keys_snapshot(
Some(¤t.app_keys),
current.created_at,
&app_keys,
event.created_at.as_secs(),
),
None => nostr_double_ratchet::AppKeysSnapshot {
decision: nostr_double_ratchet::AppKeysSnapshotDecision::Advanced,
app_keys,
created_at: event.created_at.as_secs(),
},
});
}
let Some(snapshot) = latest else {
return Ok(Vec::new());
};
Ok(snapshot
.app_keys
.get_all_devices()
.into_iter()
.map(|d| ffi_device_entry_from_app_keys(&snapshot.app_keys, d))
.collect())
}
#[uniffi::export]
pub fn resolve_conversation_candidate_pubkeys(
owner_pubkey_hex: String,
rumor_pubkey_hex: String,
rumor_tags: Vec<Vec<String>>,
sender_pubkey_hex: String,
) -> Vec<String> {
nostr_double_ratchet::resolve_conversation_candidate_pubkeys(
&owner_pubkey_hex,
&rumor_pubkey_hex,
&rumor_tags,
&sender_pubkey_hex,
)
}
#[derive(uniffi::Object)]
pub struct InviteHandle {
inner: Mutex<Invite>,
}
#[uniffi::export]
impl InviteHandle {
#[uniffi::constructor]
pub fn create_new(
inviter_pubkey_hex: String,
device_id: Option<String>,
max_uses: Option<u32>,
) -> Result<Arc<Self>, NdrError> {
let inviter = nostr_double_ratchet::utils::pubkey_from_hex(&inviter_pubkey_hex)?;
let invite = Invite::create_new(inviter, device_id, max_uses.map(|n| n as usize))?;
Ok(Arc::new(Self {
inner: Mutex::new(invite),
}))
}
#[uniffi::constructor]
pub fn from_url(url: String) -> Result<Arc<Self>, NdrError> {
let invite = Invite::from_url(&url)?;
Ok(Arc::new(Self {
inner: Mutex::new(invite),
}))
}
#[uniffi::constructor]
pub fn from_event_json(event_json: String) -> Result<Arc<Self>, NdrError> {
let event: nostr::Event = serde_json::from_str(&event_json)?;
let invite = Invite::from_event(&event)?;
Ok(Arc::new(Self {
inner: Mutex::new(invite),
}))
}
#[uniffi::constructor]
pub fn deserialize(json: String) -> Result<Arc<Self>, NdrError> {
let invite = Invite::deserialize(&json)?;
Ok(Arc::new(Self {
inner: Mutex::new(invite),
}))
}
pub fn to_url(&self, root: String) -> Result<String, NdrError> {
let invite = self.inner.lock().unwrap();
Ok(invite.get_url(&root)?)
}
pub fn to_event_json(&self) -> Result<String, NdrError> {
let invite = self.inner.lock().unwrap();
let event = invite.get_event()?;
Ok(serde_json::to_string(&event)?)
}
pub fn serialize(&self) -> Result<String, NdrError> {
let invite = self.inner.lock().unwrap();
Ok(invite.serialize()?)
}
pub fn accept(
&self,
invitee_pubkey_hex: String,
invitee_privkey_hex: String,
device_id: Option<String>,
) -> Result<InviteAcceptResult, NdrError> {
let invite = self.inner.lock().unwrap();
let invitee_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&invitee_pubkey_hex)?;
let invitee_privkey = parse_private_key(&invitee_privkey_hex)?;
let (session, response_event) =
invite.accept(invitee_pubkey, invitee_privkey, device_id)?;
let response_event_json = serde_json::to_string(&response_event)?;
Ok(InviteAcceptResult {
session: Arc::new(SessionHandle {
inner: Mutex::new(session),
}),
response_event_json,
})
}
pub fn accept_with_owner(
&self,
invitee_pubkey_hex: String,
invitee_privkey_hex: String,
device_id: Option<String>,
owner_pubkey_hex: Option<String>,
) -> Result<InviteAcceptResult, NdrError> {
let invite = self.inner.lock().unwrap();
let invitee_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&invitee_pubkey_hex)?;
let invitee_privkey = parse_private_key(&invitee_privkey_hex)?;
let owner_pubkey = match owner_pubkey_hex {
Some(h) => Some(nostr_double_ratchet::utils::pubkey_from_hex(&h)?),
None => None,
};
let (session, response_event) =
invite.accept_with_owner(invitee_pubkey, invitee_privkey, device_id, owner_pubkey)?;
let response_event_json = serde_json::to_string(&response_event)?;
Ok(InviteAcceptResult {
session: Arc::new(SessionHandle {
inner: Mutex::new(session),
}),
response_event_json,
})
}
pub fn set_purpose(&self, purpose: Option<String>) {
let mut invite = self.inner.lock().unwrap();
invite.purpose = purpose;
}
pub fn set_owner_pubkey_hex(&self, owner_pubkey_hex: Option<String>) -> Result<(), NdrError> {
let mut invite = self.inner.lock().unwrap();
invite.owner_public_key = match owner_pubkey_hex {
Some(h) => Some(nostr_double_ratchet::utils::pubkey_from_hex(&h)?),
None => None,
};
Ok(())
}
pub fn process_response(
&self,
event_json: String,
inviter_privkey_hex: String,
) -> Result<Option<InviteProcessResult>, NdrError> {
let invite = self.inner.lock().unwrap();
let event: nostr::Event = serde_json::from_str(&event_json)?;
let inviter_privkey = parse_private_key(&inviter_privkey_hex)?;
let response = invite.process_invite_response(&event, inviter_privkey)?;
let Some(response) = response else {
return Ok(None);
};
Ok(Some(InviteProcessResult {
session: Arc::new(SessionHandle {
inner: Mutex::new(response.session),
}),
invitee_pubkey_hex: response.invitee_identity.to_hex(),
device_id: response.device_id,
owner_pubkey_hex: response.owner_public_key.map(|pk| pk.to_hex()),
}))
}
pub fn get_inviter_pubkey_hex(&self) -> String {
let invite = self.inner.lock().unwrap();
invite.inviter.to_hex()
}
pub fn get_shared_secret_hex(&self) -> String {
let invite = self.inner.lock().unwrap();
hex::encode(invite.shared_secret)
}
}
#[derive(uniffi::Object)]
pub struct SessionHandle {
inner: Mutex<Session>,
}
#[uniffi::export]
impl SessionHandle {
#[uniffi::constructor]
pub fn init(
their_ephemeral_pubkey_hex: String,
our_ephemeral_privkey_hex: String,
is_initiator: bool,
shared_secret_hex: String,
name: Option<String>,
) -> Result<Arc<Self>, NdrError> {
let their_pubkey =
nostr_double_ratchet::utils::pubkey_from_hex(&their_ephemeral_pubkey_hex)?;
let our_privkey = parse_private_key(&our_ephemeral_privkey_hex)?;
let shared_secret = parse_secret(&shared_secret_hex)?;
let session = Session::init(their_pubkey, our_privkey, is_initiator, shared_secret, name)?;
Ok(Arc::new(Self {
inner: Mutex::new(session),
}))
}
#[uniffi::constructor]
pub fn from_state_json(state_json: String) -> Result<Arc<Self>, NdrError> {
let state: SessionState =
nostr_double_ratchet::utils::deserialize_session_state(&state_json)?;
let session = Session::new(state, "restored".to_string());
Ok(Arc::new(Self {
inner: Mutex::new(session),
}))
}
pub fn state_json(&self) -> Result<String, NdrError> {
let session = self.inner.lock().unwrap();
Ok(nostr_double_ratchet::utils::serialize_session_state(
&session.state,
)?)
}
pub fn can_send(&self) -> bool {
let session = self.inner.lock().unwrap();
session.can_send()
}
pub fn send_text(&self, text: String) -> Result<SendResult, NdrError> {
let mut session = self.inner.lock().unwrap();
let outer_event = session.send(text.clone())?;
let inner_event = nostr::EventBuilder::text_note(text);
let inner_event_json =
serde_json::to_string(&inner_event.build(nostr::Keys::generate().public_key()))?;
Ok(SendResult {
outer_event_json: serde_json::to_string(&outer_event)?,
inner_event_json,
})
}
pub fn decrypt_event(&self, outer_event_json: String) -> Result<DecryptResult, NdrError> {
let mut session = self.inner.lock().unwrap();
let event: nostr::Event = serde_json::from_str(&outer_event_json)?;
let plaintext = session.receive(&event)?.unwrap_or_default();
let inner_event_json = if plaintext.starts_with('{') {
plaintext.clone()
} else {
serde_json::json!({
"content": plaintext
})
.to_string()
};
Ok(DecryptResult {
plaintext,
inner_event_json,
})
}
pub fn is_dr_message(&self, event_json: String) -> bool {
if let Ok(event) = serde_json::from_str::<nostr::Event>(&event_json) {
event.kind == nostr::Kind::Custom(nostr_double_ratchet::MESSAGE_EVENT_KIND as u16)
} else {
false
}
}
}
#[derive(uniffi::Object)]
pub struct SessionManagerHandle {
runtime: NdrRuntime,
}
#[uniffi::export]
impl SessionManagerHandle {
#[uniffi::constructor]
pub fn new(
our_pubkey_hex: String,
our_identity_privkey_hex: String,
device_id: String,
owner_pubkey_hex: Option<String>,
) -> Result<Arc<Self>, NdrError> {
let our_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&our_pubkey_hex)?;
let our_identity_key = parse_private_key(&our_identity_privkey_hex)?;
let owner_pubkey = match owner_pubkey_hex {
Some(h) => nostr_double_ratchet::utils::pubkey_from_hex(&h)?,
None => our_pubkey,
};
let storage: Arc<dyn StorageAdapter> = Arc::new(InMemoryStorage::new());
let runtime = NdrRuntime::new(
our_pubkey,
our_identity_key,
device_id,
owner_pubkey,
Some(storage),
None,
);
Ok(Arc::new(Self { runtime }))
}
#[uniffi::constructor]
pub fn new_with_storage_path(
our_pubkey_hex: String,
our_identity_privkey_hex: String,
device_id: String,
storage_path: String,
owner_pubkey_hex: Option<String>,
) -> Result<Arc<Self>, NdrError> {
let our_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&our_pubkey_hex)?;
let our_identity_key = parse_private_key(&our_identity_privkey_hex)?;
let owner_pubkey = match owner_pubkey_hex {
Some(h) => nostr_double_ratchet::utils::pubkey_from_hex(&h)?,
None => our_pubkey,
};
let storage = FileStorageAdapter::new(std::path::PathBuf::from(storage_path))
.map_err(NdrError::from)?;
let storage: Arc<dyn StorageAdapter> = Arc::new(storage);
let runtime = NdrRuntime::new(
our_pubkey,
our_identity_key,
device_id,
owner_pubkey,
Some(storage),
None,
);
Ok(Arc::new(Self { runtime }))
}
pub fn init(&self) -> Result<(), NdrError> {
self.runtime.init()?;
Ok(())
}
pub fn setup_user(&self, user_pubkey_hex: String) -> Result<(), NdrError> {
let user_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&user_pubkey_hex)?;
self.runtime.setup_user(user_pubkey)?;
Ok(())
}
pub fn accept_invite_from_url(
&self,
invite_url: String,
owner_pubkey_hint_hex: Option<String>,
) -> Result<SessionManagerAcceptInviteResult, NdrError> {
let invite = Invite::from_url(&invite_url)?;
let owner_pubkey_hint = owner_pubkey_hint_hex
.as_deref()
.map(nostr_double_ratchet::utils::pubkey_from_hex)
.transpose()?;
let accepted = self.runtime.accept_invite(&invite, owner_pubkey_hint)?;
Ok(SessionManagerAcceptInviteResult {
owner_pubkey_hex: accepted.owner_pubkey.to_hex(),
inviter_device_pubkey_hex: accepted.inviter_device_pubkey.to_hex(),
device_id: accepted.device_id,
created_new_session: accepted.created_new_session,
})
}
pub fn accept_invite_from_event_json(
&self,
event_json: String,
owner_pubkey_hint_hex: Option<String>,
) -> Result<SessionManagerAcceptInviteResult, NdrError> {
let event: nostr::Event = serde_json::from_str(&event_json)?;
let invite = Invite::from_event(&event)?;
let owner_pubkey_hint = owner_pubkey_hint_hex
.as_deref()
.map(nostr_double_ratchet::utils::pubkey_from_hex)
.transpose()?;
let accepted = self.runtime.accept_invite(&invite, owner_pubkey_hint)?;
Ok(SessionManagerAcceptInviteResult {
owner_pubkey_hex: accepted.owner_pubkey.to_hex(),
inviter_device_pubkey_hex: accepted.inviter_device_pubkey.to_hex(),
device_id: accepted.device_id,
created_new_session: accepted.created_new_session,
})
}
pub fn send_text(
&self,
recipient_pubkey_hex: String,
text: String,
expires_at_seconds: Option<u64>,
) -> Result<Vec<String>, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let options = expires_at_seconds.map(|expires_at| nostr_double_ratchet::SendOptions {
expires_at: Some(expires_at),
ttl_seconds: None,
});
Ok(self.runtime.send_text(recipient, text, options)?)
}
pub fn send_text_with_inner_id(
&self,
recipient_pubkey_hex: String,
text: String,
expires_at_seconds: Option<u64>,
) -> Result<SendTextResult, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let options = expires_at_seconds.map(|expires_at| nostr_double_ratchet::SendOptions {
expires_at: Some(expires_at),
ttl_seconds: None,
});
let (inner_id, outer_event_ids) = self
.runtime
.send_text_with_inner_id(recipient, text, options)?;
Ok(SendTextResult {
inner_id,
outer_event_ids,
})
}
pub fn send_event_with_inner_id(
&self,
recipient_pubkey_hex: String,
kind: u32,
content: String,
tags_json: String,
created_at_seconds: Option<u64>,
) -> Result<SendTextResult, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let tags_vec: Vec<Vec<String>> = if tags_json.trim().is_empty() {
Vec::new()
} else {
serde_json::from_str(&tags_json)?
};
let mut ms_value: Option<u64> = None;
for t in tags_vec.iter() {
if t.first().map(|s| s.as_str()) != Some("ms") {
continue;
}
if let Some(v) = t.get(1) {
ms_value = v.parse::<u64>().ok();
break;
}
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let now_s = now.as_secs();
let now_ms = now.as_millis() as u64;
let created_at_s = created_at_seconds
.or_else(|| ms_value.map(|ms| ms / 1000))
.unwrap_or(now_s);
let mut tags: Vec<nostr::Tag> = Vec::with_capacity(tags_vec.len() + 1);
let mut has_ms = false;
for t in tags_vec {
if t.first().map(|s| s.as_str()) == Some("ms") {
has_ms = true;
}
tags.push(nostr::Tag::parse(&t).map_err(|e| NdrError::InvalidEvent(e.to_string()))?);
}
if !has_ms {
tags.push(
nostr::Tag::parse(&["ms".to_string(), now_ms.to_string()])
.map_err(|e| NdrError::InvalidEvent(e.to_string()))?,
);
}
let kind_u16: u16 = kind
.try_into()
.map_err(|_| NdrError::InvalidEvent("kind out of range".into()))?;
let owner_pubkey = self.runtime.get_owner_pubkey();
let mut event = nostr::EventBuilder::new(nostr::Kind::from(kind_u16), &content)
.tags(tags)
.custom_created_at(nostr::Timestamp::from(created_at_s))
.build(owner_pubkey);
event.ensure_id();
let inner_id = event
.id
.as_ref()
.map(|id| id.to_string())
.unwrap_or_default();
let outer_event_ids = self.runtime.send_event(recipient, event)?;
Ok(SendTextResult {
inner_id,
outer_event_ids,
})
}
pub fn send_rumor_json(
&self,
recipient_pubkey_hex: String,
rumor_json: String,
) -> Result<SendTextResult, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let event: nostr::UnsignedEvent = serde_json::from_str(&rumor_json)?;
let inner_id = unsigned_event_id_string(&event);
let outer_event_ids = self.runtime.send_event(recipient, event)?;
Ok(SendTextResult {
inner_id,
outer_event_ids,
})
}
pub fn group_upsert(&self, group: FfiGroupData) -> Result<(), NdrError> {
self.runtime.with_group_context(|_, group_manager, _| {
group_manager.upsert_group(ffi_group_data_to_group_data(group))
})?;
Ok(())
}
pub fn group_create(
&self,
name: String,
member_owner_pubkeys: Vec<String>,
fanout_metadata: Option<bool>,
now_ms: Option<u64>,
) -> Result<GroupCreateResult, NdrError> {
let member_refs: Vec<&str> = member_owner_pubkeys.iter().map(String::as_str).collect();
let should_fanout = fanout_metadata.unwrap_or(true);
self.runtime
.with_group_context(|session_manager, group_manager, _| {
let mut send_pairwise = |recipient_owner: nostr::PublicKey,
rumor: &nostr::UnsignedEvent|
-> nostr_double_ratchet::Result<()> {
session_manager.send_event(recipient_owner, rumor.clone())?;
Ok(())
};
let mut opts = CreateGroupOptions {
send_pairwise: None,
fanout_metadata: should_fanout,
now_ms,
};
if should_fanout {
opts.send_pairwise = Some(&mut send_pairwise);
}
let created = group_manager.create_group(&name, &member_refs, opts)?;
let metadata_rumor_json = created
.metadata_rumor
.as_ref()
.map(serde_json::to_string)
.transpose()?;
Ok(GroupCreateResult {
group: group_data_to_ffi_group_data(created.group),
metadata_rumor_json,
fanout: GroupCreateFanout {
enabled: created.fanout.enabled,
attempted: created.fanout.attempted as u64,
succeeded: created.fanout.succeeded,
failed: created.fanout.failed,
},
})
})
}
pub fn group_remove(&self, group_id: String) {
self.runtime
.with_group_context(|_, group_manager, _| group_manager.remove_group(&group_id));
}
pub fn group_known_sender_event_pubkeys(&self) -> Vec<String> {
self.runtime
.group_known_sender_event_pubkeys()
.into_iter()
.map(|pk| pk.to_hex())
.collect()
}
pub fn group_outer_subscription_plan(&self) -> GroupOuterSubscriptionPlanResult {
let plan = self.runtime.group_outer_subscription_plan();
GroupOuterSubscriptionPlanResult {
authors: plan.authors.into_iter().map(|pk| pk.to_hex()).collect(),
added_authors: plan
.added_authors
.into_iter()
.map(|pk| pk.to_hex())
.collect(),
}
}
pub fn group_send_event(
&self,
group_id: String,
kind: u32,
content: String,
tags_json: String,
now_ms: Option<u64>,
) -> Result<GroupSendResult, NdrError> {
let tags: Vec<Vec<String>> = if tags_json.trim().is_empty() {
Vec::new()
} else {
serde_json::from_str(&tags_json)?
};
self.runtime
.with_group_context(|session_manager, group_manager, event_tx| {
let mut send_pairwise = |recipient_owner: nostr::PublicKey,
rumor: &nostr::UnsignedEvent|
-> nostr_double_ratchet::Result<()> {
session_manager.send_event(recipient_owner, rumor.clone())?;
Ok(())
};
let mut publish_outer = |outer: &nostr::Event| -> nostr_double_ratchet::Result<()> {
event_tx
.send(SessionManagerEvent::PublishSigned(outer.clone()))
.map_err(|e| nostr_double_ratchet::Error::Storage(e.to_string()))?;
Ok(())
};
let result = group_manager.send_event(
&group_id,
GroupSendEvent {
kind,
content,
tags,
},
&mut send_pairwise,
&mut publish_outer,
now_ms,
)?;
Ok(GroupSendResult {
outer_event_json: serde_json::to_string(&result.outer)?,
inner_event_json: serde_json::to_string(&result.inner)?,
outer_event_id: result.outer.id.to_string(),
inner_event_id: unsigned_event_id_string(&result.inner),
})
})
}
pub fn group_handle_incoming_session_event(
&self,
event_json: String,
from_owner_pubkey_hex: String,
from_sender_device_pubkey_hex: Option<String>,
) -> Result<Vec<GroupDecryptedResult>, NdrError> {
let event: nostr::UnsignedEvent = serde_json::from_str(&event_json)?;
let from_owner_pubkey =
nostr_double_ratchet::utils::pubkey_from_hex(&from_owner_pubkey_hex)?;
let from_sender_device_pubkey = match from_sender_device_pubkey_hex {
Some(hex) => Some(nostr_double_ratchet::utils::pubkey_from_hex(&hex)?),
None => Some(event.pubkey),
};
let decrypted = self.runtime.group_handle_incoming_session_event(
&event,
from_owner_pubkey,
from_sender_device_pubkey,
);
Ok(decrypted
.into_iter()
.map(group_decrypted_to_result)
.collect())
}
pub fn group_handle_outer_event(
&self,
event_json: String,
) -> Result<Option<GroupDecryptedResult>, NdrError> {
let event: nostr::Event = serde_json::from_str(&event_json)?;
Ok(self
.runtime
.group_handle_outer_event(&event)
.map(group_decrypted_to_result))
}
pub fn send_receipt(
&self,
recipient_pubkey_hex: String,
receipt_type: String,
message_ids: Vec<String>,
expires_at_seconds: Option<u64>,
) -> Result<Vec<String>, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let options = expires_at_seconds.map(|expires_at| nostr_double_ratchet::SendOptions {
expires_at: Some(expires_at),
ttl_seconds: None,
});
Ok(self
.runtime
.send_receipt(recipient, &receipt_type, message_ids, options)?)
}
pub fn send_typing(
&self,
recipient_pubkey_hex: String,
expires_at_seconds: Option<u64>,
) -> Result<Vec<String>, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let options = expires_at_seconds.map(|expires_at| nostr_double_ratchet::SendOptions {
expires_at: Some(expires_at),
ttl_seconds: None,
});
Ok(self.runtime.send_typing(recipient, options)?)
}
pub fn send_reaction(
&self,
recipient_pubkey_hex: String,
message_id: String,
emoji: String,
expires_at_seconds: Option<u64>,
) -> Result<Vec<String>, NdrError> {
let recipient = nostr_double_ratchet::utils::pubkey_from_hex(&recipient_pubkey_hex)?;
let options = expires_at_seconds.map(|expires_at| nostr_double_ratchet::SendOptions {
expires_at: Some(expires_at),
ttl_seconds: None,
});
Ok(self
.runtime
.send_reaction(recipient, message_id, emoji, options)?)
}
pub fn import_session_state(
&self,
peer_pubkey_hex: String,
state_json: String,
device_id: Option<String>,
) -> Result<(), NdrError> {
let peer_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&peer_pubkey_hex)?;
let state: SessionState =
nostr_double_ratchet::utils::deserialize_session_state(&state_json)?;
self.runtime
.import_session_state(peer_pubkey, device_id, state)?;
Ok(())
}
pub fn get_active_session_state(
&self,
peer_pubkey_hex: String,
) -> Result<Option<String>, NdrError> {
let peer_pubkey = nostr_double_ratchet::utils::pubkey_from_hex(&peer_pubkey_hex)?;
if let Some(state) = self.runtime.export_active_session_state(peer_pubkey)? {
Ok(Some(nostr_double_ratchet::utils::serialize_session_state(
&state,
)?))
} else {
Ok(None)
}
}
pub fn known_peer_owner_pubkeys(&self) -> Vec<String> {
self.runtime
.known_peer_owner_pubkeys()
.into_iter()
.map(|pubkey| pubkey.to_hex())
.collect()
}
pub fn get_stored_user_record_json(
&self,
peer_owner_pubkey_hex: String,
) -> Result<Option<String>, NdrError> {
let peer_owner_pubkey =
nostr_double_ratchet::utils::pubkey_from_hex(&peer_owner_pubkey_hex)?;
Ok(self
.runtime
.get_stored_user_record_json(peer_owner_pubkey)?)
}
pub fn get_message_push_author_pubkeys(
&self,
peer_owner_pubkey_hex: String,
) -> Result<Vec<String>, NdrError> {
let peer_owner_pubkey =
nostr_double_ratchet::utils::pubkey_from_hex(&peer_owner_pubkey_hex)?;
Ok(self
.runtime
.get_message_push_author_pubkeys(peer_owner_pubkey)
.into_iter()
.map(|pubkey| pubkey.to_hex())
.collect())
}
pub fn get_message_push_session_states(
&self,
peer_owner_pubkey_hex: String,
) -> Result<Vec<MessagePushSessionStateResult>, NdrError> {
let peer_owner_pubkey =
nostr_double_ratchet::utils::pubkey_from_hex(&peer_owner_pubkey_hex)?;
self.runtime
.get_message_push_session_states(peer_owner_pubkey)
.into_iter()
.map(|snapshot| {
Ok(MessagePushSessionStateResult {
state_json: nostr_double_ratchet::utils::serialize_session_state(
&snapshot.state,
)?,
tracked_sender_pubkeys: snapshot
.tracked_sender_pubkeys
.into_iter()
.map(|pubkey| pubkey.to_hex())
.collect(),
has_receiving_capability: snapshot.has_receiving_capability,
})
})
.collect()
}
pub fn process_event(&self, event_json: String) -> Result<(), NdrError> {
let event: nostr::Event = serde_json::from_str(&event_json)?;
self.runtime.process_received_event(event);
Ok(())
}
pub fn drain_events(&self) -> Result<Vec<PubSubEvent>, NdrError> {
let mut events = Vec::new();
for event in self.runtime.drain_events() {
let pubsub_event = match event {
SessionManagerEvent::Publish(unsigned) => PubSubEvent {
kind: "publish".to_string(),
subid: None,
filter_json: None,
event_json: Some(serde_json::to_string(&unsigned)?),
sender_pubkey_hex: None,
content: None,
event_id: None,
},
SessionManagerEvent::PublishSigned(signed) => PubSubEvent {
kind: "publish_signed".to_string(),
subid: None,
filter_json: None,
event_json: Some(serde_json::to_string(&signed)?),
sender_pubkey_hex: None,
content: None,
event_id: None,
},
SessionManagerEvent::PublishSignedForInnerEvent {
event,
inner_event_id,
..
} => PubSubEvent {
kind: "publish_signed".to_string(),
subid: None,
filter_json: None,
event_json: Some(serde_json::to_string(&event)?),
sender_pubkey_hex: None,
content: None,
event_id: inner_event_id,
},
SessionManagerEvent::Subscribe { subid, filter_json } => PubSubEvent {
kind: "subscribe".to_string(),
subid: Some(subid),
filter_json: Some(filter_json),
event_json: None,
sender_pubkey_hex: None,
content: None,
event_id: None,
},
SessionManagerEvent::Unsubscribe(subid) => PubSubEvent {
kind: "unsubscribe".to_string(),
subid: Some(subid),
filter_json: None,
event_json: None,
sender_pubkey_hex: None,
content: None,
event_id: None,
},
SessionManagerEvent::DecryptedMessage {
sender,
content,
event_id,
..
} => PubSubEvent {
kind: "decrypted_message".to_string(),
subid: None,
filter_json: None,
event_json: None,
sender_pubkey_hex: Some(sender.to_hex()),
content: Some(content),
event_id,
},
SessionManagerEvent::ReceivedEvent(event) => PubSubEvent {
kind: "received_event".to_string(),
subid: None,
filter_json: None,
event_json: Some(serde_json::to_string(&event)?),
sender_pubkey_hex: None,
content: None,
event_id: None,
},
};
events.push(pubsub_event);
}
Ok(events)
}
pub fn get_device_id(&self) -> String {
self.runtime.get_device_id().to_string()
}
pub fn get_our_pubkey_hex(&self) -> String {
self.runtime.get_our_pubkey().to_hex()
}
pub fn get_owner_pubkey_hex(&self) -> String {
self.runtime.get_owner_pubkey().to_hex()
}
pub fn get_total_sessions(&self) -> u64 {
self.runtime.get_total_sessions() as u64
}
}
#[cfg(test)]
mod architecture_tests {
#[test]
fn ffi_handle_does_not_reach_into_session_manager() {
let source = include_str!("lib.rs");
let banned = concat!(".session_", "manager()");
assert!(
!source.contains(banned),
"FFI should use NdrRuntime APIs instead of direct SessionManager access"
);
}
}
fn parse_private_key(hex_str: &str) -> Result<[u8; 32], NdrError> {
let bytes = hex::decode(hex_str).map_err(|_| NdrError::InvalidKey("Invalid hex".into()))?;
if bytes.len() != 32 {
return Err(NdrError::InvalidKey("Private key must be 32 bytes".into()));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn parse_secret(hex_str: &str) -> Result<[u8; 32], NdrError> {
let bytes = hex::decode(hex_str).map_err(|_| NdrError::Serialization("Invalid hex".into()))?;
if bytes.len() != 32 {
return Err(NdrError::Serialization("Secret must be 32 bytes".into()));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn ffi_group_data_to_group_data(group: FfiGroupData) -> GroupData {
GroupData {
id: group.id,
name: group.name,
description: group.description,
picture: group.picture,
members: group.members,
admins: group.admins,
created_at: group.created_at_ms,
secret: group.secret,
accepted: group.accepted,
}
}
fn group_data_to_ffi_group_data(group: GroupData) -> FfiGroupData {
FfiGroupData {
id: group.id,
name: group.name,
description: group.description,
picture: group.picture,
members: group.members,
admins: group.admins,
created_at_ms: group.created_at,
secret: group.secret,
accepted: group.accepted,
}
}
fn unsigned_event_id_string(event: &nostr::UnsignedEvent) -> String {
event
.id
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or_default()
}
fn group_decrypted_to_result(event: GroupDecryptedEvent) -> GroupDecryptedResult {
GroupDecryptedResult {
group_id: event.group_id,
sender_event_pubkey_hex: event.sender_event_pubkey.to_hex(),
sender_device_pubkey_hex: event.sender_device_pubkey.to_hex(),
sender_owner_pubkey_hex: event.sender_owner_pubkey.map(|pk| pk.to_hex()),
outer_event_id: event.outer_event_id,
outer_created_at: event.outer_created_at,
key_id: event.key_id,
message_number: event.message_number,
inner_event_json: serde_json::to_string(&event.inner).unwrap_or_default(),
inner_event_id: unsigned_event_id_string(&event.inner),
}
}
uniffi::setup_scaffolding!();
#[derive(uniffi::Record)]
pub struct InviteProcessResult {
pub session: Arc<SessionHandle>,
pub invitee_pubkey_hex: String,
pub device_id: Option<String>,
pub owner_pubkey_hex: Option<String>,
}
#[derive(uniffi::Record)]
pub struct SendTextResult {
pub inner_id: String,
pub outer_event_ids: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
let v = version();
assert!(!v.is_empty());
assert!(v.contains('.'));
}
#[test]
fn test_keypair_generate_formats_hex() {
let kp = generate_keypair();
assert_eq!(kp.public_key_hex.len(), 64);
assert_eq!(kp.private_key_hex.len(), 64);
assert!(hex::decode(&kp.public_key_hex).is_ok());
assert!(hex::decode(&kp.private_key_hex).is_ok());
}
#[test]
fn test_derive_public_key_matches_generate() {
let kp = generate_keypair();
let pubkey = derive_public_key(kp.private_key_hex.clone()).unwrap();
assert_eq!(pubkey, kp.public_key_hex);
}
#[test]
fn test_invite_url_roundtrip() {
let kp = generate_keypair();
let invite = InviteHandle::create_new(kp.public_key_hex.clone(), None, None).unwrap();
let url = invite.to_url("https://example.com".to_string()).unwrap();
assert!(url.starts_with("https://example.com"));
let restored = InviteHandle::from_url(url).unwrap();
assert_eq!(
invite.get_inviter_pubkey_hex(),
restored.get_inviter_pubkey_hex()
);
assert_eq!(
invite.get_shared_secret_hex(),
restored.get_shared_secret_hex()
);
}
#[test]
fn test_invite_serialize_roundtrip() {
let kp = generate_keypair();
let invite =
InviteHandle::create_new(kp.public_key_hex.clone(), Some("device1".into()), Some(5))
.unwrap();
let json = invite.serialize().unwrap();
let restored = InviteHandle::deserialize(json).unwrap();
assert_eq!(
invite.get_inviter_pubkey_hex(),
restored.get_inviter_pubkey_hex()
);
assert_eq!(
invite.get_shared_secret_hex(),
restored.get_shared_secret_hex()
);
}
#[test]
fn test_invite_accept_returns_session_and_event() {
let inviter_kp = generate_keypair();
let invitee_kp = generate_keypair();
let invite =
InviteHandle::create_new(inviter_kp.public_key_hex.clone(), None, None).unwrap();
let url = invite.to_url("https://example.com".to_string()).unwrap();
let invite_copy = InviteHandle::from_url(url).unwrap();
let result = invite_copy
.accept(
invitee_kp.public_key_hex.clone(),
invitee_kp.private_key_hex.clone(),
None,
)
.unwrap();
assert!(!result.response_event_json.is_empty());
assert!(result.session.can_send());
}
#[test]
fn test_invite_process_response_yields_working_session_pair() {
let alice_kp = generate_keypair();
let bob_kp = generate_keypair();
let invite = InviteHandle::create_new(alice_kp.public_key_hex.clone(), None, None).unwrap();
let accept = invite
.accept(
bob_kp.public_key_hex.clone(),
bob_kp.private_key_hex.clone(),
None,
)
.unwrap();
let processed = invite
.process_response(
accept.response_event_json.clone(),
alice_kp.private_key_hex.clone(),
)
.unwrap()
.unwrap();
let bob_send = accept.session.send_text("hi".to_string()).unwrap();
let alice_decrypt = processed
.session
.decrypt_event(bob_send.outer_event_json.clone())
.unwrap();
assert!(alice_decrypt.plaintext.contains("hi"));
assert!(processed.session.can_send());
let alice_reply = processed.session.send_text("ok".to_string()).unwrap();
let bob_decrypt = accept
.session
.decrypt_event(alice_reply.outer_event_json)
.unwrap();
assert!(bob_decrypt.plaintext.contains("ok"));
}
#[test]
fn test_session_send_receive() {
let alice_kp = generate_keypair();
let bob_kp = generate_keypair();
let invite = InviteHandle::create_new(alice_kp.public_key_hex.clone(), None, None).unwrap();
let invite_json = invite.serialize().unwrap();
let bob_invite = InviteHandle::deserialize(invite_json).unwrap();
let accept_result = bob_invite
.accept(
bob_kp.public_key_hex.clone(),
bob_kp.private_key_hex.clone(),
None,
)
.unwrap();
let bob_session = accept_result.session;
assert!(bob_session.can_send());
let send_result = bob_session.send_text("Hello Alice!".to_string()).unwrap();
assert!(!send_result.outer_event_json.is_empty());
}
#[test]
fn test_session_state_roundtrip() {
let alice_kp = generate_keypair();
let bob_kp = generate_keypair();
let invite = InviteHandle::create_new(alice_kp.public_key_hex.clone(), None, None).unwrap();
let invite_json = invite.serialize().unwrap();
let bob_invite = InviteHandle::deserialize(invite_json).unwrap();
let accept_result = bob_invite
.accept(
bob_kp.public_key_hex.clone(),
bob_kp.private_key_hex.clone(),
None,
)
.unwrap();
let session = accept_result.session;
let state_json = session.state_json().unwrap();
let restored = SessionHandle::from_state_json(state_json).unwrap();
assert_eq!(session.can_send(), restored.can_send());
}
#[test]
fn test_group_send_event_tracks_sender_event_pubkey() {
let kp = generate_keypair();
let manager = SessionManagerHandle::new(
kp.public_key_hex.clone(),
kp.private_key_hex.clone(),
kp.public_key_hex.clone(),
None,
)
.unwrap();
manager.init().unwrap();
manager
.group_upsert(FfiGroupData {
id: "group-ffi-test".to_string(),
name: "ffi".to_string(),
description: None,
picture: None,
members: vec![kp.public_key_hex.clone()],
admins: vec![kp.public_key_hex.clone()],
created_at_ms: 1_700_000_000_000,
secret: None,
accepted: Some(true),
})
.unwrap();
assert!(manager.group_known_sender_event_pubkeys().is_empty());
let send = manager
.group_send_event(
"group-ffi-test".to_string(),
14,
"hello".to_string(),
"[]".to_string(),
Some(1_700_000_000_000),
)
.unwrap();
assert!(!send.outer_event_json.is_empty());
assert!(!send.inner_event_json.is_empty());
assert!(!send.outer_event_id.is_empty());
assert!(!send.inner_event_id.is_empty());
let sender_event_pubkeys = manager.group_known_sender_event_pubkeys();
assert_eq!(
sender_event_pubkeys.len(),
0,
"local sender-event pubkeys should be filtered from subscription lists"
);
}
#[test]
fn test_group_create_returns_group_and_metadata_rumor() {
let kp = generate_keypair();
let manager = SessionManagerHandle::new(
kp.public_key_hex.clone(),
kp.private_key_hex.clone(),
kp.public_key_hex.clone(),
None,
)
.unwrap();
manager.init().unwrap();
let created = manager
.group_create(
"ffi-created".to_string(),
vec![kp.public_key_hex.clone()],
Some(true),
Some(1_700_000_123_000),
)
.unwrap();
assert_eq!(created.group.name, "ffi-created");
assert!(created.group.members.contains(&kp.public_key_hex));
assert!(created.metadata_rumor_json.is_some());
assert!(created.fanout.enabled);
}
#[test]
fn test_send_rumor_json_preserves_inner_id() {
let alice = generate_keypair();
let bob = generate_keypair();
let sender_device = generate_keypair();
let manager = SessionManagerHandle::new(
alice.public_key_hex.clone(),
alice.private_key_hex.clone(),
alice.public_key_hex.clone(),
None,
)
.unwrap();
manager.init().unwrap();
let our_next = nostr::Keys::generate();
let state = SessionState {
root_key: [7u8; 32],
their_current_nostr_public_key: Some(
nostr_double_ratchet::utils::pubkey_from_hex(&bob.public_key_hex).unwrap(),
),
their_next_nostr_public_key: None,
our_current_nostr_key: None,
our_next_nostr_key: nostr_double_ratchet::SerializableKeyPair {
public_key: our_next.public_key(),
private_key: our_next.secret_key().secret_bytes(),
},
receiving_chain_key: None,
sending_chain_key: Some([9u8; 32]),
sending_chain_message_number: 0,
receiving_chain_message_number: 0,
previous_sending_chain_message_count: 0,
skipped_keys: std::collections::HashMap::new(),
};
manager
.import_session_state(
bob.public_key_hex.clone(),
nostr_double_ratchet::utils::serialize_session_state(&state).unwrap(),
Some("bob-device".to_string()),
)
.unwrap();
let rumor = nostr::EventBuilder::new(nostr::Kind::Custom(14), "raw rumor")
.tags(vec![
nostr::Tag::parse(&["l".to_string(), "group-ffi-test".to_string()]).unwrap(),
nostr::Tag::parse(&["ms".to_string(), "1700000000000".to_string()]).unwrap(),
])
.custom_created_at(nostr::Timestamp::from(1_700_000_000))
.build(
nostr_double_ratchet::utils::pubkey_from_hex(&sender_device.public_key_hex)
.unwrap(),
);
let rumor_json = serde_json::to_string(&rumor).unwrap();
let send = manager
.send_rumor_json(bob.public_key_hex.clone(), rumor_json)
.unwrap();
assert_eq!(
send.inner_id,
rumor.id.as_ref().map(ToString::to_string).unwrap()
);
}
#[test]
fn test_is_dr_message() {
let kp = generate_keypair();
let invite = InviteHandle::create_new(kp.public_key_hex.clone(), None, None).unwrap();
let bob_kp = generate_keypair();
let accept_result = invite
.accept(
bob_kp.public_key_hex.clone(),
bob_kp.private_key_hex.clone(),
None,
)
.unwrap();
let session = accept_result.session;
let send_result = session.send_text("test".to_string()).unwrap();
assert!(session.is_dr_message(send_result.outer_event_json));
let non_dr_event = serde_json::json!({
"id": "0000000000000000000000000000000000000000000000000000000000000000",
"pubkey": "0000000000000000000000000000000000000000000000000000000000000000",
"created_at": 0,
"kind": 1,
"tags": [],
"content": "test",
"sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
});
assert!(!session.is_dr_message(non_dr_event.to_string()));
}
#[test]
fn test_app_keys_labels_roundtrip() {
let owner = generate_keypair();
let laptop = generate_keypair();
let phone = generate_keypair();
let event_json = create_signed_app_keys_event(
owner.public_key_hex.clone(),
owner.private_key_hex.clone(),
vec![
FfiDeviceEntry {
identity_pubkey_hex: laptop.public_key_hex.clone(),
created_at: 1_700_000_000,
device_label: Some("Sirius MacBook".to_string()),
client_label: Some("Iris Chat Desktop".to_string()),
},
FfiDeviceEntry {
identity_pubkey_hex: phone.public_key_hex.clone(),
created_at: 1_700_000_100,
device_label: Some("Linked device".to_string()),
client_label: Some("Iris Chat Mobile".to_string()),
},
],
)
.unwrap();
let parsed =
parse_app_keys_event(event_json.clone(), Some(owner.private_key_hex.clone())).unwrap();
let resolved =
resolve_latest_app_keys_devices(vec![event_json], Some(owner.private_key_hex)).unwrap();
for devices in [parsed, resolved] {
assert_eq!(devices.len(), 2);
let laptop_entry = devices
.iter()
.find(|entry| entry.identity_pubkey_hex == laptop.public_key_hex)
.unwrap();
assert_eq!(laptop_entry.device_label.as_deref(), Some("Sirius MacBook"));
assert_eq!(
laptop_entry.client_label.as_deref(),
Some("Iris Chat Desktop")
);
let phone_entry = devices
.iter()
.find(|entry| entry.identity_pubkey_hex == phone.public_key_hex)
.unwrap();
assert_eq!(phone_entry.device_label.as_deref(), Some("Linked device"));
assert_eq!(
phone_entry.client_label.as_deref(),
Some("Iris Chat Mobile")
);
}
}
}