nextcloud / nextcloud/ios

Uploaded photos are silently not removed from the camera roll

Open
#4,306 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Swift
Stars
2.5k
Forks
1k
Avg merge
2d 18h
Merged PRs (30d)
13

Description

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
  1. Activate auto upload and deletion of pictures
  2. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up β€” it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with NCManageDatabase+CreateMetadata.swift and NCManageDatabase+Metadata.swift, then trace readFolder through updateMetadatasFilesAsync and readFile through addMetadataAsync. Reproduce a folder refresh after upload and inspect the assetLocalIdentifier used by getAssetLocalIdentifiersUploadedAsync. Done means server refreshes no longer make uploaded photos unavailable to camera-roll cleanup.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, swift
Domain
databases, mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.