diff --git a/Cargo.lock b/Cargo.lock index ddbe939..e7241ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,7 @@ dependencies = [ "kordophone", "kordophone-db", "log", + "once_cell", "serde_json", "thiserror 2.0.12", "tokio", diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql new file mode 100644 index 0000000..a549445 --- /dev/null +++ b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql @@ -0,0 +1,14 @@ +-- Revert participants table to remove contact_id column +-- SQLite does not support DROP COLUMN directly, so we recreate the table without contact_id +PRAGMA foreign_keys=off; +CREATE TABLE participants_backup ( + id INTEGER NOT NULL PRIMARY KEY, + display_name TEXT, + is_me BOOL NOT NULL +); +INSERT INTO participants_backup (id, display_name, is_me) + SELECT id, display_name, is_me + FROM participants; +DROP TABLE participants; +ALTER TABLE participants_backup RENAME TO participants; +PRAGMA foreign_keys=on; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql new file mode 100644 index 0000000..c72b96b --- /dev/null +++ b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql @@ -0,0 +1,2 @@ +-- Add contact_id column to participants to store an external contact identifier (e.g., Folks ID) +ALTER TABLE participants ADD COLUMN contact_id TEXT; \ No newline at end of file diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index 0ce7150..959f8a9 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -8,6 +8,7 @@ pub struct Record { pub id: i32, pub display_name: Option, pub is_me: bool, + pub contact_id: Option, } #[derive(Insertable)] @@ -15,19 +16,22 @@ pub struct Record { pub struct InsertableRecord { pub display_name: Option, pub is_me: bool, + pub contact_id: Option, } impl From for InsertableRecord { fn from(participant: Participant) -> Self { match participant { - Participant::Me => InsertableRecord { - display_name: None, - is_me: true, - }, - Participant::Remote { display_name, .. } => InsertableRecord { - display_name: Some(display_name), - is_me: false, - }, + Participant::Me => InsertableRecord { + display_name: None, + is_me: true, + contact_id: None, + }, + Participant::Remote { display_name, contact_id, .. } => InsertableRecord { + display_name: Some(display_name), + is_me: false, + contact_id, + }, } } } @@ -50,6 +54,7 @@ impl From for Participant { Participant::Remote { id: Some(record.id), display_name: record.display_name.unwrap_or_default(), + contact_id: record.contact_id, } } } @@ -58,16 +63,18 @@ impl From for Participant { impl From for Record { fn from(participant: Participant) -> Self { match participant { - Participant::Me => Record { - id: 0, // This will be set by the database - display_name: None, - is_me: true, - }, - Participant::Remote { display_name, .. } => Record { - id: 0, // This will be set by the database - display_name: Some(display_name), - is_me: false, - }, + Participant::Me => Record { + id: 0, // This will be set by the database + display_name: None, + is_me: true, + contact_id: None, + }, + Participant::Remote { display_name, contact_id, .. } => Record { + id: 0, // This will be set by the database + display_name: Some(display_name), + is_me: false, + contact_id, + }, } } } diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index d57e086..75d0cab 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -29,6 +29,7 @@ impl From for Message { Some(sender) => Participant::Remote { id: None, display_name: sender, + contact_id: None, }, None => Participant::Me, }, diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index f643202..ce9d37d 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -4,6 +4,7 @@ pub enum Participant { Remote { id: Option, display_name: String, + contact_id: Option, }, } @@ -12,6 +13,7 @@ impl From for Participant { Participant::Remote { id: None, display_name, + contact_id: None, } } } @@ -21,6 +23,7 @@ impl From<&str> for Participant { Participant::Remote { id: None, display_name: display_name.to_string(), + contact_id: None, } } } diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 0f104ad..b6787cb 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -13,8 +13,7 @@ use crate::{ }, Conversation, Message, Participant, }, - schema, - target, + schema, target, }; pub struct Repository<'a> { @@ -195,6 +194,7 @@ impl<'a> Repository<'a> { let new_participant = InsertableParticipantRecord { display_name: Some(display_name.clone()), is_me: false, + contact_id: None, }; diesel::insert_into(participants_dsl::participants) @@ -371,11 +371,27 @@ impl<'a> Repository<'a> { ) } + /// Update the contact_id for an existing participant record. + pub fn update_participant_contact( + &mut self, + participant_db_id: i32, + new_contact_id: &str, + ) -> Result<()> { + use crate::schema::participants::dsl::*; + + log::debug!(target: target::REPOSITORY, "Updating participant contact id {} => {}", participant_db_id, new_contact_id); + diesel::update(participants.filter(id.eq(participant_db_id))) + .set(contact_id.eq(Some(new_contact_id.to_string()))) + .execute(self.connection)?; + Ok(()) + } + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, Participant::Remote { display_name: p_name, + contact_id: c_id, .. } => { use crate::schema::participants::dsl::*; @@ -393,6 +409,7 @@ impl<'a> Repository<'a> { let participant_record = InsertableParticipantRecord { display_name: Some(participant.display_name()), is_me: false, + contact_id: c_id.clone(), }; diesel::insert_into(participants) diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index d0c5355..8f1f554 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -16,6 +16,7 @@ diesel::table! { id -> Integer, display_name -> Nullable, is_me -> Bool, + contact_id -> Nullable, } } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 1222916..51e88b2 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" anyhow = "1.0.98" async-trait = "0.1.88" chrono = "0.4.38" -dbus = "0.9.7" +dbus = { version = "0.9.7", features = ["futures"] } dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" dbus-tree = "0.9.2" @@ -23,6 +23,7 @@ thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } tokio-condvar = "0.3.0" uuid = "1.16.0" +once_cell = "1.19.0" [build-dependencies] dbus-codegen = "0.10.0" diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs new file mode 100644 index 0000000..a913cf4 --- /dev/null +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -0,0 +1,229 @@ +use super::ContactResolverBackend; +use dbus::blocking::Connection; +use dbus::arg::{RefArg, Variant}; +use once_cell::sync::OnceCell; +use std::collections::HashMap; +use std::time::Duration; + +pub struct EDSContactResolverBackend; + +// Cache the UID of the default local address book so we do not have to scan +// all sources over and over again. Discovering the address book requires a +// D-Bus round-trip that we would rather avoid on every lookup. +static ADDRESS_BOOK_SOURCE_UID: OnceCell = OnceCell::new(); + +/// Helper that returns a blocking D-Bus session connection. Creating the +/// connection is cheap (<1 ms) but we still keep it around because the +/// underlying socket is re-used by the dbus crate. +fn new_session_connection() -> Result { + Connection::new_session() +} + +/// Scan Evolution-Data-Server sources to find a suitable address-book source +/// UID. The implementation mirrors what `gdbus introspect` reveals for the +/// EDS interfaces. We search all `org.gnome.evolution.dataserver.Source` +/// objects and pick the first one that advertises the `[Address Book]` section +/// with a `BackendName=` entry in its INI-style `Data` property. +fn ensure_address_book_uid(conn: &Connection) -> anyhow::Result { + if let Some(uid) = ADDRESS_BOOK_SOURCE_UID.get() { + return Ok(uid.clone()); + } + + let source_manager_proxy = conn.with_proxy( + "org.gnome.evolution.dataserver.Sources5", + "/org/gnome/evolution/dataserver/SourceManager", + Duration::from_secs(5), + ); + + // The GetManagedObjects reply is the usual ObjectManager map. + let (managed_objects,): ( + HashMap< + dbus::Path<'static>, + HashMap>>>, + >, + ) = source_manager_proxy.method_call( + "org.freedesktop.DBus.ObjectManager", + "GetManagedObjects", + (), + )?; + + let uid = managed_objects + .values() + .filter_map(|ifaces| ifaces.get("org.gnome.evolution.dataserver.Source")) + .filter_map(|props| { + let uid = props.get("UID")?.as_str()?; + let data = props.get("Data")?.as_str()?; + if data_contains_address_book_backend(data) { + Some(uid.to_owned()) + } else { + None + } + }) + .next() + .ok_or_else(|| anyhow::anyhow!("No address book source found"))?; + + // Remember for future look-ups. + log::debug!("EDS resolver: found address book source UID: {}", uid); + let _ = ADDRESS_BOOK_SOURCE_UID.set(uid.clone()); + Ok(uid) +} + +fn data_contains_address_book_backend(data: &str) -> bool { + let mut in_address_book_section = false; + for line in data.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_address_book_section = trimmed == "[Address Book]"; + continue; + } + if in_address_book_section && trimmed.starts_with("BackendName=") { + return true; + } + } + false +} + +/// Open the Evolution address book referenced by `source_uid` and return the +/// pair `(object_path, bus_name)` that identifies the newly created D-Bus +/// proxy. +fn open_address_book( + conn: &Connection, + source_uid: &str, +) -> anyhow::Result<(String, String)> { + let factory_proxy = conn.with_proxy( + "org.gnome.evolution.dataserver.AddressBook10", + "/org/gnome/evolution/dataserver/AddressBookFactory", + Duration::from_secs(60), + ); + + let (object_path, bus_name): (String, String) = factory_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBookFactory", + "OpenAddressBook", + (source_uid.to_owned(),), + )?; + + Ok((object_path, bus_name)) +} + +impl ContactResolverBackend for EDSContactResolverBackend { + type ContactID = String; + + fn resolve_contact_id(&self, address: &str) -> Option { + // Only email addresses are supported for now. We fall back to NONE on + // any error to keep the resolver infallible for callers. + let conn = match new_session_connection() { + Ok(c) => c, + Err(e) => { + log::debug!("EDS resolver: failed to open session D-Bus: {}", e); + return None; + } + }; + + let source_uid = match ensure_address_book_uid(&conn) { + Ok(u) => u, + Err(e) => { + log::debug!("EDS resolver: could not determine address-book UID: {}", e); + return None; + } + }; + + let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: failed to open address book: {}", e); + return None; + } + }; + + let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + + let filter = if address.contains('@') { + format!("(is \"email\" \"{}\")", address) + } else { + // Remove country code, if present + let address = address.replace("+", "") + .chars() + .skip_while(|c| c.is_numeric() || *c == '(' || *c == ')') + .collect::(); + + // Remove any remaining non-numeric characters + let address = address.chars() + .filter(|c| c.is_numeric()) + .collect::(); + + format!("(is \"phone\" \"{}\")", address) + }; + + let uids_result: Result<(Vec,), _> = address_book_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBook", + "GetContactListUids", + (filter,), + ); + + let (uids,) = match uids_result { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: GetContactListUids failed: {}", e); + return None; + } + }; + + uids.into_iter().next() + } + + fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option { + let conn = match new_session_connection() { + Ok(c) => c, + Err(e) => { + log::debug!("EDS resolver: failed to open session D-Bus: {}", e); + return None; + } + }; + + let source_uid = match ensure_address_book_uid(&conn) { + Ok(u) => u, + Err(e) => { + log::debug!("EDS resolver: could not determine address-book UID: {}", e); + return None; + } + }; + + let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: failed to open address book: {}", e); + return None; + } + }; + + let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + + let vcard_result: Result<(String,), _> = address_book_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBook", + "GetContact", + (contact_id.clone(),), + ); + + let (vcard,) = match vcard_result { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: GetContact failed: {}", e); + return None; + } + }; + + for line in vcard.lines() { + if let Some(rest) = line.strip_prefix("FN:") { + return Some(rest.to_string()); + } + } + + None + } +} + +impl Default for EDSContactResolverBackend { + fn default() -> Self { + Self + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/contact_resolver/mod.rs b/kordophoned/src/daemon/contact_resolver/mod.rs new file mode 100644 index 0000000..2271fae --- /dev/null +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -0,0 +1,46 @@ +pub mod eds; +pub use eds::EDSContactResolverBackend; + +pub trait ContactResolverBackend { + type ContactID; + + fn resolve_contact_id(&self, address: &str) -> Option; + fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option; +} + +pub type AnyContactID = String; + +pub struct ContactResolver { + backend: T, +} + +impl ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + pub fn new(backend: T) -> Self { + Self { backend } + } + + pub fn resolve_contact_id(&self, address: &str) -> Option { + self.backend.resolve_contact_id(address).map(|id| id.into()) + } + + pub fn get_contact_display_name(&self, contact_id: &AnyContactID) -> Option { + let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone()); + self.backend.get_contact_display_name(&backend_contact_id) + } +} + +impl Default for ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + fn default() -> Self { + Self::new(T::default()) + } +} diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 3d10d3c..546424f 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -49,6 +49,12 @@ mod attachment_store; pub use attachment_store::AttachmentStore; pub use attachment_store::AttachmentStoreEvent; +pub mod contact_resolver; +use contact_resolver::ContactResolver; +use contact_resolver::EDSContactResolverBackend; + +use kordophone_db::models::participant::Participant as DbParticipant; + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -501,10 +507,34 @@ impl Daemon { // Insert each conversation let num_conversations = db_conversations.len(); + let contact_resolver = ContactResolver::new(EDSContactResolverBackend::default()); for conversation in db_conversations { + // Insert or update conversation and its participants database - .with_repository(|r| r.insert_conversation(conversation)) + .with_repository(|r| r.insert_conversation(conversation.clone())) .await?; + + // Resolve any new participants via the contact resolver and store their contact_id + log::trace!(target: target::SYNC, "Resolving participants for conversation: {}", conversation.guid); + let guid = conversation.guid.clone(); + if let Some(saved) = database + .with_repository(|r| r.get_conversation_by_guid(&guid)) + .await? + { + for p in &saved.participants { + if let DbParticipant::Remote { id: Some(pid), display_name, contact_id: None } = p { + log::trace!(target: target::SYNC, "Resolving contact id for participant: {}", display_name); + if let Some(contact) = contact_resolver.resolve_contact_id(display_name) { + log::trace!(target: target::SYNC, "Resolved contact id for participant: {}", contact); + let _ = database + .with_repository(|r| r.update_participant_contact(*pid, &contact)) + .await; + } else { + log::trace!(target: target::SYNC, "No contact id found for participant: {}", display_name); + } + } + } + } } // Send conversations updated signal diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index f1bc32f..7b68ae8 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -13,6 +13,7 @@ pub enum Participant { Remote { id: Option, display_name: String, + contact_id: Option, }, } @@ -21,6 +22,7 @@ impl From for Participant { Participant::Remote { id: None, display_name, + contact_id: None, } } } @@ -30,6 +32,7 @@ impl From<&str> for Participant { Participant::Remote { id: None, display_name: display_name.to_string(), + contact_id: None, } } } @@ -38,8 +41,8 @@ impl From for Participant { fn from(participant: kordophone_db::models::Participant) -> Self { match participant { kordophone_db::models::Participant::Me => Participant::Me, - kordophone_db::models::Participant::Remote { id, display_name } => { - Participant::Remote { id, display_name } + kordophone_db::models::Participant::Remote { id, display_name, contact_id } => { + Participant::Remote { id, display_name, contact_id } } } } @@ -107,8 +110,8 @@ impl From for kordophone_db::models::Message { id: message.id, sender: match message.sender { Participant::Me => kordophone_db::models::Participant::Me, - Participant::Remote { id, display_name } => { - kordophone_db::models::Participant::Remote { id, display_name } + Participant::Remote { id, display_name, contact_id } => { + kordophone_db::models::Participant::Remote { id, display_name, contact_id } } }, text: message.text, @@ -145,6 +148,7 @@ impl From for Message { Some(sender) => Participant::Remote { id: None, display_name: sender, + contact_id: None, }, None => Participant::Me, }, diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index cb42995..e8ecc60 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -9,8 +9,11 @@ use crate::daemon::{ settings::Settings, signals::Signal, DaemonResult, + contact_resolver::{ContactResolver, EDSContactResolverBackend}, }; +use kordophone_db::models::participant::Participant; + use crate::dbus::endpoint::DbusRegistry; use crate::dbus::interface; use crate::dbus::interface::signals as DbusSignals; @@ -167,6 +170,24 @@ impl DBusAgent { .unwrap() .map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e))) } + + fn resolve_participant_display_name(&self, participant: &Participant) -> String { + let resolver = ContactResolver::new(EDSContactResolverBackend::default()); + match participant { + // Me (we should use a special string here...) + Participant::Me => "(Me)".to_string(), + + // Remote participant with a resolved contact_id + Participant::Remote { display_name, contact_id: Some(contact_id), .. } => { + resolver.get_contact_display_name(contact_id).unwrap_or_else(|| display_name.clone()) + } + + // Remote participant without a resolved contact_id + Participant::Remote { display_name, .. } => { + display_name.clone() + } + } + } } // @@ -207,7 +228,7 @@ impl DbusRepository for DBusAgent { arg::Variant(Box::new( conv.participants .into_iter() - .map(|p| p.display_name()) + .map(|p| self.resolve_participant_display_name(&p)) .collect::>(), )), ); @@ -221,6 +242,7 @@ impl DbusRepository for DBusAgent { }) } + fn sync_conversation_list(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::SyncConversationList) }