REPRODUCIBILITY:
OS VERSION: 5.2.0.17
HARDWARE: JP2
UI LANGUAGE: Dutch
REGRESSION:
DESCRIPTION:
Syncing contacts from linux pc/thunderbird/cardbook & radicale v3 to sfos cardav/caldav account doesn’t work. Calendar sync works well.
PRECONDITIONS:
Recently I cleaned out my addressbook in cardbook/thunderbird and would expect to see these changes on my JP2026 but they don’t.
STEPS TO REPRODUCE:
Delete or alter contact on the pc with thunderbird and cardbook plugin
Sync addressbook to radicale server
try to get sfos to sync contacts by long pressing ‘caldav & cardav account’ in settings -» accounts:
EXPECTED RESULT:
Deleted contacts deleted on the phone too. Altered contacts on pc to be altered on the phone too.
ACTUAL RESULT:
A contact added on the phone is synced on the pc, but doubled, tripled, quadrupled …
Alterations or deletions on the pc are not transferred to the phone. Calendar sync works 2 ways as expected.
MODIFICATIONS:
ADDITIONAL INFORMATION:
1 Like
attah
26 August 2026 19:33
2
Please fill in the bug report template completely.
What is the server here? What relevance does “pc” and “Thuinderbird” have?
Have you checked the server - whatever it is - agrees the changes has happened?
Well I have a radicale server running and i can sync my contacts as also the calendar. Just remember that probably the account on your phone wants https…so you have to add http://yourradicale:5232 as server if it’s not on https. Also look at the logging from radicale, it shows which paths you have to use.
And for your calendar make sure you use the right calendar ‘Manage Calendars’.
1 Like
I sync 3 linux pc’s in my home network with a radicale server on my main pc and that works flawless since years. Also updated radicale to v3
I got my calendar & contacts on my phone by creating a caldav/cardav account and that synced the first time all my contacts and calendar.
Thx for the info but I did all that the way you discribed. Calendar sync works well. Contacts added on the phone get to thunderbird (sometimes doubled) but new contact or changes from thunderbird don’t get to the phone. But thx for your answer.
My Xperia 10III seems to share the same issue. It’s on sfos 5.1.0.11. Not getting contacts synced from thunderbird w radicale on home network. Calendar syncing ok.
So in theory I could delete my caldav/cardav acount on the phone and recreate it every time I want to sync my contacts. Then it would download the adressbook from radicale in its latest form … ?
Contact & calendar syncing works here (radicale server, Evolution on laptop, SfOS). But I remember having had difficulties with that long ago…
I can confirm this bug!
Jolla should have got a PR for this, time to accept it and roll out the fixed libcarddav-client.so.
Contribute to JimKnopfIoT/buteo-sync-plugin-carddav development by creating an account on GitHub.
master ← JimKnopfIoT:fix-discarded-remote-deletions
opened 06:35AM - 03 Sep 26 UTC
Contacts deleted on the server are never deleted on the device, and the sync
st… ill reports success.
**Cause.** In `CardDav::calculateContactChanges()` the lambda `appendMatches`
is handed `m_remoteRemovals`, which is keyed by addressbook url on the outer
level; the contact uri only lives in the inner hash. Matching the contact uri
against the outer hash can never hit, so the `removed` list always stays
empty. Present since fca9a0f (2020).
**Why it is easy to miss.** The deletions are fetched, the sync-token is
advanced past them and the sync reports `majorcode=0`. Because the token has
moved on, the server never reports those deletions again — the contact stays
on the device for good, with no error anywhere. I believe this is behind the
recurring "sync works for a while, then stops, never an error message"
reports, and it explains why deleting and re-creating the account helps: that
throws away the diverged local state together with the token.
**Fix.** Take the addressbook's inner hash once and match the uri against
that. Nine lines, no behaviour change beyond the intended one.
**Reproduce.** Set up a CardDAV account, sync, delete a contact on the server,
sync again. The contact stays on the device. In the log, `Have calculated
A/M/R/U` reports `R=0` no matter what was removed remotely.
**Testing.** Against Nextcloud 33.0.7 (Synology NAS):
| Device | Sailfish OS | Arch | Plugin | qtcontacts-sqlite |
|---|---|---|---|---|
| Jolla Phone (jp2601) | 5.2.0.17 | aarch64 | 0.1.13-1.13.3 | 0.3.24 |
| Xperia 10 III (xqbt52) | 5.1.0.11 | aarch64 | 0.1.13-1.13.2 | 0.3.21 |
| Gemini PDA (geminipda) | 4.6.0.13 | armv7hl | 0.1.10-1.11.2 | 0.3.19 |
Effective on all three: remote deletions now arrive on the device.
This is the smallest of the deletion problems and independent of the other
one; the follow-up (412 on DELETE, delete-vs-modify conflicts) is in a
separate PR, since the two touch different code paths.
master ← JimKnopfIoT:fix-deletion-conflicts
opened 06:38AM - 03 Sep 26 UTC
Once a deletion is refused with `412 Precondition Failed`, every following
sync… of the account fails at the same point, until the account is deleted and
set up again. This is the problem discussed in #12 and in
https://forum.sailfishos.org/t/contacts-synchronization-issue-with-nextcloud/30849
**Why 412 and not 404.** Deletions are upsynced with `If-Match`. RFC 7232 has
the server evaluate the precondition *before* it checks whether the resource
exists, so deleting a resource that is already gone answers 412. And
`upsyncResponse()` tolerates only 405, so the pending deletion is never
cleared and the next sync runs into the same wall.
**Why not simply ignore the 412** (the approach in #12): the status code has
two very different causes.
1. the contact is gone — our deletion has effectively succeeded;
2. it was modified elsewhere, our etag is stale — a real conflict.
Both answer 412 and nothing in the response tells them apart. Ignoring the
error is correct for (1) and silent data loss for (2): the contact disappears
from the device while it lives on, changed, on the server. I built that
version first and it does clear the stuck sync — which is exactly what makes
it dangerous.
**So ask.** On a 412 for a deletion, `GET` the resource:
* `404`/`410` — really gone, the deletion succeeded;
* `200` — still there and changed elsewhere: keep the server version, put it
into the remote change set and take the local deletion back.
Anything else, including a `GET` that cannot be sent at all, keeps the contact
rather than losing it. A plain `404` on a DELETE is now accepted as success as
well.
**The conflict that causes it.** A contact deleted on the device and modified
elsewhere is never recognised as a conflict in the first place:
`TwoWayContactSyncAdaptor::remoteContactChangesDetermined()` compares incoming
remote changes against the modified and the unmodified contacts, never against
the removed ones. So the DELETE goes out with a stale etag (→ 412) while
`ContactWriter::updateContact()` rejects the remote change because the row is
already flagged deleted. The contact ends up gone locally, orphaned on the
server, with the sync moving past it.
The remote modifications for the addressbook are known before the DELETE is
sent, so the third commit checks against them and resolves the conflict
without a round trip. **This is a fix in the plugin for a gap in the
adaptor** — the proper place is `remoteContactChangesDetermined()` in
qtcontacts-sqlite, which would fix it for every consumer. Happy to prepare
that separately if you would rather have it there.
While in there: the profile setting `conflictpolicy` is not evaluated
anywhere. `m_conflictResPolicy` is read and never used, and
`ContactWriter::storeChanges()` has `Q_UNUSED(conflictResolutionPolicy);
// TODO`. What is implemented here — remote wins — is what the plugin does
everywhere else.
**Undelete.** Taking a local deletion back needs a `QContactUndelete` detail:
qtcontacts-sqlite rejects changes to a row flagged as deleted, and
`clearChangeFlags()` would purge exactly the row we are keeping. The ids are
collected separately rather than derived from the change lists, because a
contact whose server copy could not be fetched appears in neither list and
must still survive.
**The same failure on a modification** (added after the discussion in #12).
Julien pointed out that all of the above is gated on the request being a
deletion, and he is right: a `PUT` refused with 412 still falls into the final
`else` and aborts. It needs the same treatment, at two moments.
*Before the request.* If the uri of a locally modified contact is already known
to be removed remotely, a conditional `PUT` could only be refused. The contact
goes up without a precondition and its local row is protected from the incoming
removal - the mirror of the deletion path above. No round trip, and no 412.
*After a 412.* Probe with `GET`, as for a deletion. `200` - changed elsewhere;
the server's version and its etag replace ours, so the row ends up consistent
and the next sync has nothing to do. `404`/`410` - gone; recreated with an
unconditional `PUT`, which is deliberately not marked as a modification so a
412 on it cannot send us round the probe again. Anything else - the sync fails,
as it does today.
That last branch is deliberate rather than "leave it pending and retry":
`storeRemoteChangesLocally()` calls `storeChanges()` with
`clearChangeFlags = true`, and `ContactWriter::clearChangeFlags(collectionId)`
selects *every* contact in the collection and does
`UPDATE Contacts SET changeFlags = unhandledChangeFlags`. A sync that ends
successfully therefore clears a pending modification whether or not it was
upsynced, so ending in error is the only way to keep it.
**Commits.** Four, reviewable one by one: a pure refactor (contact building
split out of `parseContactData()`, no functional change), the 412 handling for
deletions, the delete-vs-modify conflict detection, and the same for
modifications.
**Testing.** Against Nextcloud 33.0.7 (Synology NAS):
| Device | Sailfish OS | Arch | Plugin | qtcontacts-sqlite |
|---|---|---|---|---|
| Jolla Phone (jp2601) | 5.2.0.17 | aarch64 | 0.1.13-1.13.3 | 0.3.24 |
| Xperia 10 III (xqbt52) | 5.1.0.11 | aarch64 | 0.1.13-1.13.2 | 0.3.21 |
| Gemini PDA (geminipda) | 4.6.0.13 | armv7hl | 0.1.10-1.11.2 | 0.3.19 |
A deletion that used to wedge the sync now completes, and a contact changed
on the server while it was deleted on the device survives with the server
version. The full delete-vs-modify scenario was reproduced end to end on the
Jolla Phone; on the other two only the absence of regressions was verified.
The user who originally reported the problem confirms it is fixed.
For the modification case, both branches were run end to end on the Jolla Phone
and the Xperia and branch 200 also on the Gemini, first with the stock library
to confirm the abort, then with this patch. The pre-request case only means
anything with #13 applied, since that is what makes the removal visible at all,
so it was measured that way. Every run was checked afterwards for convergence:
the row's etag, the pending change flags, and a follow-up no-op sync.
Everything here is measured against one server, Nextcloud 33.0.7 and SabreDAV's
reading of RFC 7232. A server that answers 412 differently, or returns something
other than 404/410 for a resource it has dropped, takes the third branch and
fails the sync rather than resolving it.
Depends on nothing else, but is best read after #13 — that bug produces the
stale local contacts whose deletion then runs into the 412.
master ← JimKnopfIoT:fix-conflict-merge
opened 05:54PM - 03 Sep 26 UTC
# Keep local detail changes from being dropped by the conflict merge
A contac… t modified on the device and on the server loses the local change
without a word. The device ends up with the server's value, the sync reports
success, and nothing anywhere says an edit was discarded. It is not a rare
race: it happens every time both sides touch the same contact between two
syncs.
## Cause
`TwoWayContactSyncAdaptor::resolveConflictingChanges()` is built to apply the
local delta onto the remote version — its own comment says *"note: this will
clobber the remote detail if it was also modified"*. But it can only do that
for a detail whose persistent database id it recognises:
```cpp
if (ldet.type() == rdet.type()
&& (localDetailDbId > 0 && localDetailDbId == remoteDetailDbId)) {
```
`CardDav::calculateContactChanges()` annotates the remote contacts with the
matching local *contact* id (`c.setId(matchingId)`) and nothing else. A contact
parsed from a vCard carries no detail database ids, so `remoteDetailDbId` is
always 0, the branch never fires, and a flagged local modification or deletion
of an existing detail silently falls out of the merge. Locally *added* details
take a different branch and do survive, which is why this is easy to miss.
The root cause is arguably that the merge needs persistent detail ids at all —
that is in qtcontacts-sqlite, and fixing it there would help every sync plugin.
This patch takes the smaller route: supply what the merge is missing.
## What it does
On the remote contacts it already annotates, also attach the local detail
database ids — but only where the pairing is unambiguous. Two passes:
**Equal values first.** That proves identity. Live local details get first
refusal, so a deleted one cannot claim the detail its unchanged twin should
keep, which would otherwise depend on their order in the list. A value the
remote side carries more than once is skipped entirely: it cannot be told
apart, and stamping an id on one copy would stop `resolveConflictingChanges()`
collapsing the duplicate, since two details that compared equal no longer do
once one of them carries an id.
**Then the sole remaining candidate of its type on either side**, counting only
details that are still unpaired. The values differ — that is the conflict — but
there is only one thing it can be.
Deliberately not paired:
* **Local deletions, except by equal value.** Equal count is not identity. A
deletion paired by count with a detail the other side has just changed would
remove that new value from the merge and then upsync its absence — turning a
lost edit into a lost detail.
* **Local additions, at all.** The merge applies them directly without looking
up an id, and pairing one risks putting the same row id on two details of the
same contact.
* **Contacts with no flagged local detail**, and contacts matched in the added
or removed lists, since only the modified and unmodified ones reach the merge.
Values are compared with the adaptor's own `ignorableDetailsAndFields()`, so
the comparison ignores what it ignores: the normalized phone number, and the
address, phone and url subtypes, all of which differ between the database
version of a contact and the vCard version without saying anything about the
content. Contexts and subtypes are stored as `QList<int>`, and a `QVariant`
holding one does not compare equal even to an identical copy — qtpim carries
the same workaround in `QContactDetailPrivate`. An absent field and an empty
one mean the same thing and are treated alike.
## What changes for users
For a detail modified on both sides, the local value now wins where the remote
one used to. That is the adaptor's documented intent, not a new policy: the
branch exists solely for adapters that can supply persistent detail ids, and
`TwoWayContactSyncAdaptor` says as much where it invites the method to be
overridden. Worth noting in passing that the profile setting
`conflictpolicy = "prefer remote"` in `carddav.Contacts.xml` is declared and
never read — `grep -rn conflictpolicy src/` finds only the two XML files — so
the previous behaviour was not that policy being honoured either.
## Testing
Against Nextcloud 33.0.7:
| Device | Sailfish OS | Arch | Stock plugin | qtcontacts-sqlite |
|---|---|---|---|---|
| Jolla Phone (jp2601) | 5.2.0.17 | aarch64 | 0.1.13-1.13.3 | 0.3.24 |
| Xperia 10 III (xqbt52) | 5.1.0.11 | aarch64 | 0.1.13-1.13.2 | 0.3.21 |
| Gemini PDA (geminipda) | 4.6.0.13 | armv7hl | 0.1.10-1.11.2 | 0.3.19 |
| Case | aarch64 | armv7hl |
|---|---|---|
| two numbers, one changed locally, server changed the name | yes | yes |
| one number, changed on both sides | yes | yes |
| detail deleted locally, server left it alone → deletion propagates | yes | — |
| detail deleted locally, server changed it → server's value survives | — | yes |
| number added locally alongside an incoming change | yes | — |
Each run was checked afterwards for convergence: the pending change flags, the
stored etag, and a follow-up no-op sync. The two-number case is the one to look
at hardest — an earlier version of this patch passed every single-detail test
and still did nothing at all for a contact with two phone numbers, because it
counted every detail of the type rather than the unpaired ones.
## What this does not cover
Three phones against one server. The shapes where I would expect trouble are
the ones the code refuses to guess at: several details of one type where more
than one changed on both sides, duplicate values, servers that emit repeated
vCard lines. In those it attaches nothing and the previous behaviour stands —
that is a deliberate choice rather than a proof, and it is the part most worth
a second opinion.
Independent of #13 and #14, though it addresses the finding that came out of
the discussion in #12.
master ← JimKnopfIoT:report-target-results
opened 06:39PM - 03 Sep 26 UTC
The plugin writes a bare `<syncresults/>` element into the profile's sync log
a… nd nothing else - no counts, no per-contact detail, not even which addressbook
the sync touched. `grep -rn TargetResults src/` comes back empty.
That matters most where the plugin deliberately keeps going after a failure. A
`405` on a read-only collection is logged as a warning and the sync then reports
success; a deletion whose server uri is unknown is skipped with a `// TODO: this
is actually an error` and never mentioned again. Neither leaves a trace outside
the journal, and journald is configured `Storage=volatile` on this platform, so
after a reboot the evidence is gone. The sync log is what survives.
The structure for this already exists and `buteo-sync-plugin-caldav` already
fills it in. `Buteo::TargetResults` carries an `ItemCounts` per target for the
local and the remote side; `addLocalDetails()` and `addRemoteDetails()` record
one entry per item with an operation, a status and a free-text message, and the
counts follow the successful ones. `SyncResults::addTargetResults()` attaches
them and the whole thing round-trips through the sync log XML.
## What is recorded
* **Remote changes**, as they are handed to the sync adaptor to be applied
locally - additions, modifications and deletions. `storeChanges()` reports
failure for a collection as a whole rather than per contact, so these are
recorded as they are applied and a failure there ends the sync.
* **Local changes**, when the upsync reply arrives: succeeded, or failed with
the reason. Today one reason exists - `405 Method Not Allowed`, where the
sync continues on purpose.
* **A deletion with no known server uri**, as a failure rather than a silent
`continue`.
* **Every addressbook that took part**, even when nothing changed, so that a
quiet sync says so rather than leaving the log silent.
The uid reported is the server-side one. The plugin's internal guid would serve
as an identifier too, but it is prefixed with the account id and the full
collection path; the details already hang off one addressbook, so the prefix
would put account data into the log for no gain.
## What it looks like
```xml
<syncresults majorcode="0" minorcode="0" scheduled="false" time="…">
<target name="Contacts">
<local deleted="0" modified="0" added="1">
<addedItem uid="d91d0c8db5dc4975a4ddaff56c4e6a8e"/>
</local>
<remote deleted="1" modified="0" added="0">
<deletedItem uid="85d655b8a9954181a419b0c66fd49b00"/>
</remote>
</target>
</syncresults>
```
## Testing
Against Nextcloud 33.0.7, on a Jolla Phone (jp2601, Sailfish OS 5.2.0.17,
aarch64, plugin 0.1.13-1.13.3), with a second device to produce the incoming
changes: an addition and a deletion upsynced, an addition applied locally, and a
sync with no changes at all. Counts and per-item entries match in every case.
## What it does not do
Nothing in the UI surfaces any of this - it reaches the sync log and stops
there. And the plugin resolves some conflicts silently by design: a local
deletion that loses against a remote modification, a modification that loses
against a remote change. Each of those deserves an entry of its own, but they do
not exist on master, so they are not recorded here.
Purely additive: 138 lines, no existing line removed.
3 Likes