Bug report
How to use GitHub
- Please use the 👍 reaction to show that you are affected by the same issue.
- Please don't comment if you have no relevant information to add. It's just extra noise for everyone subscribed to this issue.
- Subscribe to receive notifications on status change and new comments.
Steps to reproduce
- Activate auto upload and deletion of pictures
- use phone and Nextcloud App
Expected behaviour
Uploaded pictures should be deleted from camera roll (at least user should be prompted to delete them)
Actual behaviour
Some pictures don't get deleted (AFAI concerned, this amounts to hundreds of pictures over a few months)
Logs
See below
Reasoning or why should it be changed/implemented?
Relieve user from the pain of comparing hundreds of picturesbetween phone and server, then manually deleting them from Camera Roll
Environment data
Code references are against upstream master at
204f9e23b1 (2026-08-23). Log lines are upstream's own except the getCameraRollAssets: count line,
which was added locally and is the measurement used below. Usernames redacted.
Device: iPhone 12 mini, iOS 26.6.1, "Remove from camera roll after upload" enabled, Wi-Fi at home.
Analysis
Summary
The only link from an uploaded file back to its camera-roll asset is tableMetadata.assetLocalIdentifier,
set once when auto-upload queues the asset. Camera-roll cleanup fetches every row whose identifier is
non-empty and deletes those assets. Any refresh of the folder's contents from the server — opening the
folder in the Files tab, or a single-file read — rebuilds the rows from NKFile via
convertFileToMetadata, which never populates that field, and writes them over the existing rows. The
identifier is gone; the photo stays in the camera roll forever; no error, no retry.
Measured on one day: of 40 photos uploaded before the user opened the folder, the 17 that were still
pending at that moment were removed; the 22 already uploaded were never offered again. The 34
uploaded the previous day were never offered either. The library sat at that floor until the tester
cleared it by hand.
Contracts that apply
The identifier is the handle —
PHObject.localIdentifier:
A unique string that persistently identifies the object. Use this string to find the object by using
the fetchAssets(withLocalIdentifiers:options:) … method.
Upstream cleanup does exactly that (NCNetworkingProcess.swift:339-354): getAssetLocalIdentifiersUploadedAsync()
→ PHAsset.fetchAssets(withLocalIdentifiers:) → PHAssetChangeRequest.deleteAssets. There is no other
route from a tableMetadata row to a PHAsset; the server knows nothing about local identifiers.
What realm.add(_:update: .all) does — RealmSwift v20.0.5, Realm.UpdatePolicy:
.all — Overwrite all properties in the existing object with the new values, even if they have not
changed.
.modified — Overwrite only properties in the existing object which are different from the new
values.
Both policies replace a stored non-empty string with an incoming empty one ("" is a different
value), so neither preserves a field the incoming object doesn't carry. Preservation has to be explicit.
Where the code departs from it
The field is only ever set at queue time — git grep "assetLocalIdentifier =" on master finds two
writers, both at queue time (NCAutoUpload.swift:134, NCUploadAssetsModel.swift:301), and one
deliberate clear after a confirmed deletion (NCManageDatabase+Metadata.swift:789).
Cleanup depends on it being non-empty — NCManageDatabase+Metadata.swift:1304-1309:
func getAssetLocalIdentifiersUploadedAsync() async -> [String]? {
return await core.performRealmReadAsync { realm in
let results = realm.objects(tableMetadata.self).filter("assetLocalIdentifier != ''")
return results.map { $0.assetLocalIdentifier }
}
}
Server-sourced rows never carry it — NCManageDatabase+CreateMetadata.swift (convertFileToMetadata*)
contains zero references to assetLocalIdentifier; rows built from a PROPFIND have the default "".
Those rows are written over the uploaded ones. After a successful upload the row's primary key
ocId is replaced by the server's file id (uploadSuccess, NCNetworking+Upload.swift:308;
NCMetadataUploadTranfersSuccess.swift:38), so every later server-sourced row for that file has the same
primary key. Two write paths then overwrite it:
Folder refresh — readFolder (NCNetworking+WebDAV.swift:20-37) → updateMetadatasFilesAsync
(NCManageDatabase+Metadata.swift:695-731), which deletes every status == Normal row in the folder
and re-inserts the server's list:
let resultsToDelete = realm.objects(tableMetadata.self)
.filter("account == %@ AND serverUrl == %@ AND status == %d AND fileName != %@", account, serverUrl, NCGlobal.shared.metadataStatusNormal, …)
.filter { !ocIdsToSkip.contains($0.ocId) }
realm.delete(resultsToDelete)
for metadata in metadatas {
guard !ocIdsToSkip.contains(metadata.ocId) else { continue }
realm.add(metadata.detachedCopy(), update: .all)
}
Uploaded photos have status == Normal. Only rows still in a transfer (status != Normal) are in
ocIdsToSkip and survive. This runs whenever the folder is opened in the Files tab
(NCCollectionViewCommon+SyncMetadata.swift:146) or in the folder picker (NCSelect.swift:549).
Single-file refresh — readFile (NCNetworking+WebDAV.swift:42-83) → addMetadataAsync →
realm.add(detached, update: .all) (NCManageDatabase+Metadata.swift:457-461; six more update: .all
sites in the same file for the batch variants).
Field evidence — log-5b.txt, 2026-09-13
Tester's note for the day (verbatim): "For some reason, some pictures were not deleted from the camera
roll."
The measurement is unfilteredLibraryCount, the total number of assets in the Camera Roll collection at
each discovery pass. Every drop is a removal; the floor it returns to is what cleanup can no longer see.
Morning: 40 photos discovered, 20 dispatched, 18 left pending
08:05:21 getCameraRollAssets: … filteredCount=0, unfilteredLibraryCount=36 ← 34 assets from the previous day already "handled"
08:13:52 getCameraRollAssets: … filteredCount=2, unfilteredLibraryCount=38
08:13:56 Uploading file 26-09-13 08-09-05 0615.heic … ← 0615, 0616
13:37:19 getCameraRollAssets: … filteredCount=38, unfilteredLibraryCount=76
13:37:21 Uploading file 26-09-13 08-40-07 0617.heic …
… (0617 … 0636 dispatched — 20, the per-pass cap; 0627.mov deferred as chunked)
14:22:08 Uploading file 26-09-13 09-03-39 0637.heic … ← one more on the next BGTask
At this point 0615–0626, 0628–0637 (22 photos) have completed on the background session and their rows
are status == Normal. 0638–0654 (17 photos) are still waitUpload, and 0627.mov is deferred. No upload
error of any kind appears in the log for the day; none of 0615–0637 is ever dispatched again.
14:58 — the tester opens the Photos folder in the app
14:58:11 Application will enter in foreground
14:58:37 Request started: PROPFIND …/Photos/2026/09 "Depth": "1" ← folder listing → updateMetadatasFilesAsync
14:58:39 Request started: PROPFIND …/Photos/2026/09 "Depth": "1" ← again
14:58:48 [SUCCESS] Uploaded file: …/26-09-12 12-36-37 0596.mov ← chunked, foreground
14:59:28 [SUCCESS] Uploaded file: …/26-09-13 08-41-08 0627.mov ← chunked, foreground
14:59:29 Uploading file 26-09-13 09-03-39 0638.heic …
… (0638 … 0654 — the 17 pending photos — dispatched 14:59:29–14:59:32)
Two depth-1 listings of exactly the folder the photos were uploaded to, after the 22 completed and
before the 17 pending ones were dispatched. Per updateMetadatasFilesAsync, the 22 Normal rows were
deleted and re-inserted from the server without identifiers; the 17 waitUpload rows were in
ocIdsToSkip and kept theirs.
15:03 — cleanup removes exactly the 17
15:03:06 Application will enter in foreground
15:03:07 getCameraRollAssets: … filteredCount=0, unfilteredLibraryCount=59 ← 76 − 17
Seventeen assets removed: the number of photos whose rows were still pending when the folder was listed.
The 22 that were already uploaded — and the 34 from the previous day — stayed.
The rest of the day: the floor never moves
19:17:05 getCameraRollAssets: … filteredCount=6, unfilteredLibraryCount=65 ← 6 new
19:19:14 getCameraRollAssets: … filteredCount=0, unfilteredLibraryCount=59 ← 6 removed, floor 59
20:48 … 22:51 ← 34 more discovered and uploaded
22:55:43 getCameraRollAssets: … filteredCount=0, unfilteredLibraryCount=93
22:57:54 getCameraRollAssets: … filteredCount=0, unfilteredLibraryCount=59 ← 34 removed, floor 59
Two later cleanups each removed precisely the new batch and nothing else. The floor of 59 (= 2 baseline
- 34 from the previous day + 23 from the morning batch) held for the rest of the day and into the next
(log-6/log-7: 59 → 62 → 64 → 60 after four more photos came and went), until the tester emptied the
library by hand at ~00:20 on 09-14. Nothing in any log ever offered those 57 again.
Two details this log cannot settle: the two chunked videos that completed between the listing and the
cleanup (0596, 0627) are not distinguishable in the count, and the 34 previous-day assets' uploads
predate the surviving logs (discovery treated them as already handled at 08:05, and the tester can
confirm their presence on the server).
With an explicit "never overwrite a stored identifier with an empty one"
Every batch since has matched exactly: 7 of 7 and 8 of 8 (log-c), 24 (log-d), 11 (log-h), 4 then 57
(log-i), 12 + 1 (log-j). Note, though, that none of those runs contains a depth-1 listing of the photos
folder — the folder-refresh path above has not been exercised since; see the last section.
Test conditions
2026-09-13 (log-5b): ordinary day at home on Wi-Fi with auto-upload and "remove after upload" on.
Photos taken 08:09–10:55; app left backgrounded; background passes at 08:13, 13:37, 14:22 discovered and
dispatched; app foregrounded and the Photos folder browsed at 14:58; further photos 19:09–22:40 with
foreground checks at 19:17 and 22:55. Tester's observation at the end of the day is quoted above.
Note on scope of any fix
The two overwrite paths are different in shape. The single-file and batch addMetadata* writers can be
made to preserve the stored identifier when the incoming one is empty. updateMetadatasFilesAsync
cannot, as written: it deletes the rows first, so by the time the server rows are inserted there is no
stored value left to preserve — it needs to carry the identifiers across the delete, or exclude
identifier-bearing rows from the delete. A fix that covers only the addMetadata* sites leaves the
folder-refresh path — the one this log shows — untouched.
Bug report
How to use GitHub
Steps to reproduce
Expected behaviour
Uploaded pictures should be deleted from camera roll (at least user should be prompted to delete them)
Actual behaviour
Some pictures don't get deleted (AFAI concerned, this amounts to hundreds of pictures over a few months)
Logs
See below
Reasoning or why should it be changed/implemented?
Relieve user from the pain of comparing hundreds of picturesbetween phone and server, then manually deleting them from Camera Roll
Environment data
Code references are against upstream
masterat204f9e23b1(2026-08-23). Log lines are upstream's own except thegetCameraRollAssets:count line,which was added locally and is the measurement used below. Usernames redacted.
Device: iPhone 12 mini, iOS 26.6.1, "Remove from camera roll after upload" enabled, Wi-Fi at home.
Analysis
Summary
The only link from an uploaded file back to its camera-roll asset is
tableMetadata.assetLocalIdentifier,set once when auto-upload queues the asset. Camera-roll cleanup fetches every row whose identifier is
non-empty and deletes those assets. Any refresh of the folder's contents from the server — opening the
folder in the Files tab, or a single-file read — rebuilds the rows from
NKFileviaconvertFileToMetadata, which never populates that field, and writes them over the existing rows. Theidentifier is gone; the photo stays in the camera roll forever; no error, no retry.
Measured on one day: of 40 photos uploaded before the user opened the folder, the 17 that were still
pending at that moment were removed; the 22 already uploaded were never offered again. The 34
uploaded the previous day were never offered either. The library sat at that floor until the tester
cleared it by hand.
Contracts that apply
The identifier is the handle —
PHObject.localIdentifier:Upstream cleanup does exactly that (
NCNetworkingProcess.swift:339-354):getAssetLocalIdentifiersUploadedAsync()→
PHAsset.fetchAssets(withLocalIdentifiers:)→PHAssetChangeRequest.deleteAssets. There is no otherroute from a
tableMetadatarow to aPHAsset; the server knows nothing about local identifiers.What
realm.add(_:update: .all)does — RealmSwift v20.0.5,Realm.UpdatePolicy:Both policies replace a stored non-empty string with an incoming empty one (
""is a differentvalue), so neither preserves a field the incoming object doesn't carry. Preservation has to be explicit.
Where the code departs from it
The field is only ever set at queue time —
git grep "assetLocalIdentifier ="on master finds twowriters, both at queue time (
NCAutoUpload.swift:134,NCUploadAssetsModel.swift:301), and onedeliberate clear after a confirmed deletion (
NCManageDatabase+Metadata.swift:789).Cleanup depends on it being non-empty —
NCManageDatabase+Metadata.swift:1304-1309:Server-sourced rows never carry it —
NCManageDatabase+CreateMetadata.swift(convertFileToMetadata*)contains zero references to
assetLocalIdentifier; rows built from a PROPFIND have the default"".Those rows are written over the uploaded ones. After a successful upload the row's primary key
ocIdis replaced by the server's file id (uploadSuccess,NCNetworking+Upload.swift:308;NCMetadataUploadTranfersSuccess.swift:38), so every later server-sourced row for that file has the sameprimary key. Two write paths then overwrite it:
Folder refresh —
readFolder(NCNetworking+WebDAV.swift:20-37) →updateMetadatasFilesAsync(
NCManageDatabase+Metadata.swift:695-731), which deletes everystatus == Normalrow in the folderand re-inserts the server's list:
Uploaded photos have
status == Normal. Only rows still in a transfer (status != Normal) are inocIdsToSkipand survive. This runs whenever the folder is opened in the Files tab(
NCCollectionViewCommon+SyncMetadata.swift:146) or in the folder picker (NCSelect.swift:549).Single-file refresh —
readFile(NCNetworking+WebDAV.swift:42-83) →addMetadataAsync→realm.add(detached, update: .all)(NCManageDatabase+Metadata.swift:457-461; six moreupdate: .allsites in the same file for the batch variants).
Field evidence — log-5b.txt, 2026-09-13
Tester's note for the day (verbatim): "For some reason, some pictures were not deleted from the camera
roll."
The measurement is
unfilteredLibraryCount, the total number of assets in the Camera Roll collection ateach discovery pass. Every drop is a removal; the floor it returns to is what cleanup can no longer see.
Morning: 40 photos discovered, 20 dispatched, 18 left pending
At this point 0615–0626, 0628–0637 (22 photos) have completed on the background session and their rows
are
status == Normal. 0638–0654 (17 photos) are stillwaitUpload, and 0627.mov is deferred. No uploaderror of any kind appears in the log for the day; none of 0615–0637 is ever dispatched again.
14:58 — the tester opens the Photos folder in the app
Two depth-1 listings of exactly the folder the photos were uploaded to, after the 22 completed and
before the 17 pending ones were dispatched. Per
updateMetadatasFilesAsync, the 22 Normal rows weredeleted and re-inserted from the server without identifiers; the 17
waitUploadrows were inocIdsToSkipand kept theirs.15:03 — cleanup removes exactly the 17
Seventeen assets removed: the number of photos whose rows were still pending when the folder was listed.
The 22 that were already uploaded — and the 34 from the previous day — stayed.
The rest of the day: the floor never moves
Two later cleanups each removed precisely the new batch and nothing else. The floor of 59 (= 2 baseline
(log-6/log-7: 59 → 62 → 64 → 60 after four more photos came and went), until the tester emptied the
library by hand at ~00:20 on 09-14. Nothing in any log ever offered those 57 again.
Two details this log cannot settle: the two chunked videos that completed between the listing and the
cleanup (0596, 0627) are not distinguishable in the count, and the 34 previous-day assets' uploads
predate the surviving logs (discovery treated them as already handled at 08:05, and the tester can
confirm their presence on the server).
With an explicit "never overwrite a stored identifier with an empty one"
Every batch since has matched exactly: 7 of 7 and 8 of 8 (log-c), 24 (log-d), 11 (log-h), 4 then 57
(log-i), 12 + 1 (log-j). Note, though, that none of those runs contains a depth-1 listing of the photos
folder — the folder-refresh path above has not been exercised since; see the last section.
Test conditions
2026-09-13 (log-5b): ordinary day at home on Wi-Fi with auto-upload and "remove after upload" on.
Photos taken 08:09–10:55; app left backgrounded; background passes at 08:13, 13:37, 14:22 discovered and
dispatched; app foregrounded and the Photos folder browsed at 14:58; further photos 19:09–22:40 with
foreground checks at 19:17 and 22:55. Tester's observation at the end of the day is quoted above.
Note on scope of any fix
The two overwrite paths are different in shape. The single-file and batch
addMetadata*writers can bemade to preserve the stored identifier when the incoming one is empty.
updateMetadatasFilesAsynccannot, as written: it deletes the rows first, so by the time the server rows are inserted there is no
stored value left to preserve — it needs to carry the identifiers across the delete, or exclude
identifier-bearing rows from the delete. A fix that covers only the
addMetadata*sites leaves thefolder-refresh path — the one this log shows — untouched.