imsg_session/contacts/sync.rs
1//! Version-aware PBAP contact sync: skips the full pull entirely when the device reports an
2//! unchanged phonebook identity/version watermark.
3
4use pbap_core::client::PbapClient;
5use pbap_core::phonebook::PhonebookPath;
6use pbap_core::PhonebookMetadata;
7use store::{NewContact, PbapMeta, Store};
8use tokio::io::{AsyncRead, AsyncWrite};
9
10use crate::util::now_ms;
11
12/// What a [`sync_contacts`] call did.
13///
14/// Distinguishes the two outcomes a bare count can't: a phonebook whose watermark still matches
15/// the cache (nothing fetched) from a refresh that ran and happened to write nothing.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SyncReport {
18 /// Device reported an unchanged `DatabaseIdentifier` + version counters, so no vCards were
19 /// pulled. The cache is current, and the `contacts_synced`/`contacts_synced_at` markers are
20 /// still stamped — this is a successful sync, not a skipped one.
21 UpToDate,
22 /// A full refresh ran; see [`Refresh`] for what it did and didn't manage to store.
23 Refreshed(Refresh),
24}
25
26/// Per-entry accounting for one phonebook refresh.
27///
28/// `listed` is what the device offered; the rest describe what became of it. Entries are lost
29/// silently on the device side (`pull_failed`) or the parse side (`no_uid`), so
30/// `written < listed` is the signal that the cache is an incomplete view of the phonebook.
31///
32/// The counters don't sum to `listed`: PBAP's `0.vcf` owner card is listed but deliberately
33/// never pulled, and it carries no counter of its own.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct Refresh {
36 /// Entries the device's listing reported, including the skipped owner card.
37 pub listed: usize,
38 /// Entries whose vCard fetch failed; logged and skipped, never fatal to the sync.
39 pub pull_failed: usize,
40 /// Fetched vCards carrying no `UID`. Unstorable — `UID` is the cache's primary key.
41 pub no_uid: usize,
42 /// Contacts handed to the store.
43 pub written: usize,
44 /// `true` when a *previously established* `DatabaseIdentifier` changed, discarding the whole
45 /// cache before this refresh — every cached UID was invalidated rather than updated in place.
46 /// A first-ever sync leaves this `false`: it has no prior identity to invalidate.
47 pub wiped: bool,
48}
49
50// hex-encodes a 16-byte PBAP identifier/counter for storage in the text-only meta table
51fn hex16(bytes: [u8; 16]) -> String {
52 use std::fmt::Write as _;
53 let mut s = String::with_capacity(32);
54 for b in bytes {
55 let _ = write!(s, "{b:02x}");
56 }
57 s
58}
59
60fn to_pbap_meta(m: &PhonebookMetadata) -> PbapMeta {
61 PbapMeta {
62 database_id: m.database_id.map(hex16),
63 primary_version: m.primary_version.map(hex16),
64 secondary_version: m.secondary_version.map(hex16),
65 }
66}
67
68// skips PBAP's 0.vcf owner card, and any entry whose pull fails or whose vCard carries no UID
69// (the store's primary key) — a per-entry failure is logged, counted, and doesn't abort the
70// sync. wiped is passed through to the report rather than decided here; the cache-wipe
71// decision belongs to sync_contacts, which owns the watermark comparison
72async fn refresh_contacts<T: AsyncRead + AsyncWrite + Unpin>(
73 client: &mut PbapClient<T>,
74 store: &Store,
75 path: PhonebookPath,
76 wiped: bool,
77) -> anyhow::Result<Refresh> {
78 let entries = client.list(path, None, 0).await?;
79 let listed = entries.len();
80 let mut pull_failed = 0usize;
81 let mut fetched = Vec::with_capacity(listed);
82 for entry in &entries {
83 if entry.handle() == "0.vcf" {
84 continue;
85 }
86 match client.pull(path, entry.handle()).await {
87 Ok(contact) => fetched.push(contact),
88 Err(e) => {
89 pull_failed = pull_failed.saturating_add(1);
90 tracing::warn!("pbap pull {} failed, skipping: {e}", entry.handle());
91 }
92 }
93 }
94 let pulled = fetched.len();
95 let new_contacts: Vec<NewContact> = fetched
96 .into_iter()
97 .filter_map(|c| {
98 let phones = c.phones().to_vec();
99 Some(NewContact { uid: c.uid?, display_name: c.display_name, phones })
100 })
101 .collect();
102 let written = new_contacts.len();
103 store.upsert_contacts(new_contacts).await?;
104 Ok(Refresh { listed, pull_failed, no_uid: pulled.saturating_sub(written), written, wiped })
105}
106
107/// Syncs the store's contact cache with the device's phonebook at `path`, skipping the refresh
108/// when the device reports an unchanged `DatabaseIdentifier` + version counters.
109///
110/// Fetches a metadata-only listing first (no vCard bodies). If the identity/version watermark
111/// matches the cached one, returns [`SyncReport::UpToDate`] with no further requests. A changed
112/// `DatabaseIdentifier` wipes the cache before refreshing (previously cached UIDs may no longer
113/// be valid); a version-counter-only change refreshes without wiping. A `DatabaseIdentifier` the
114/// device never reports (`None`) can't establish cache validity, so every call refreshes.
115///
116/// On any success path (including the no-op), sets the `contacts_synced` meta flag — the
117/// contacts-domain counterpart to `sync_enabled`, read by callers deciding whether the local
118/// contacts cache is trustworthy enough to read directly — and stamps
119/// [`Store::set_contacts_synced_at`] for freshness display.
120///
121/// # Errors
122///
123/// Returns an error if the metadata fetch, phonebook refresh, or any store write fails. Failures
124/// pulling an individual vCard are reported in [`Refresh`], not returned here.
125pub async fn sync_contacts<T: AsyncRead + AsyncWrite + Unpin>(
126 client: &mut PbapClient<T>,
127 store: &Store,
128 path: PhonebookPath,
129) -> anyhow::Result<SyncReport> {
130 let remote = to_pbap_meta(&client.phonebook_metadata(path).await?);
131 let cached = store.pbap_meta().await?;
132
133 if remote.database_id.is_some() && remote == cached {
134 store.set_meta("contacts_synced", "true").await?;
135 store.set_contacts_synced_at(now_ms()).await?;
136 return Ok(SyncReport::UpToDate);
137 }
138 let cleared = remote.database_id.is_some() && remote.database_id != cached.database_id;
139 if cleared {
140 store.clear_contacts().await?;
141 }
142 // A first-ever sync also "clears" (an empty cache), but nothing was invalidated by it —
143 // only report a wipe when a previously established identity was replaced.
144 let wiped = cleared && cached.database_id.is_some();
145
146 let refresh = refresh_contacts(client, store, path, wiped).await?;
147 store.set_pbap_meta(&remote).await?;
148 store.set_meta("contacts_synced", "true").await?;
149 store.set_contacts_synced_at(now_ms()).await?;
150 Ok(SyncReport::Refreshed(refresh))
151}
152
153#[cfg(test)]
154mod tests;