Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c83905f
feat: add Photos background upload extension
marinofaggiana Aug 27, 2026
8fec657
feat: enable background photo upload extension
marinofaggiana Aug 27, 2026
5bfe6ce
feat: embed background upload extension
marinofaggiana Aug 27, 2026
f2af31e
test
marinofaggiana Aug 27, 2026
813bab1
fix: disable auto upload on iOS 27
marinofaggiana Aug 27, 2026
945f1f7
feat: acknowledge background photo upload jobs
marinofaggiana Aug 27, 2026
0c49ab7
test
marinofaggiana Aug 27, 2026
5cbc4fa
lib
marinofaggiana Aug 27, 2026
c512b05
feat: enable database access in background upload extension
marinofaggiana Aug 28, 2026
61cd561
refactor: move background upload configuration to Brand
marinofaggiana Aug 28, 2026
0c46437
feat: complete background upload job lifecycle
marinofaggiana Aug 28, 2026
2b63aed
feat: delegate auto uploads to Photos extension
marinofaggiana Aug 28, 2026
b873487
feat: set up accounts for background uploads
marinofaggiana Aug 28, 2026
ea4be8d
refactor: split background upload extension workflows
marinofaggiana Aug 28, 2026
09206cd
cleaning
marinofaggiana Aug 28, 2026
4b83071
refactor: clean up background upload extension code
marinofaggiana Aug 28, 2026
7124eab
feat: queue assets for background auto upload
marinofaggiana Aug 28, 2026
903eb1e
fix: handle orphaned background upload jobs
marinofaggiana Aug 28, 2026
c33b076
refactor: extract background upload discovery
marinofaggiana Aug 28, 2026
fc53dcd
lint
marinofaggiana Aug 28, 2026
fa8d944
fix: honor network settings for background uploads
marinofaggiana Aug 29, 2026
723a79e
fix: respect account settings for background auto uploads
marinofaggiana Aug 29, 2026
d3127ec
fix: harden background upload job processing
marinofaggiana Aug 29, 2026
2c1ac99
feat: persist background upload retry state
marinofaggiana Aug 29, 2026
b6f4c0e
fix: register metadata tags in background upload database
marinofaggiana Aug 30, 2026
ae9b60e
fix: respect background upload job capacity
marinofaggiana Aug 31, 2026
e1b9050
fix: limit auto upload to one account
marinofaggiana Aug 31, 2026
694286a
fix: coordinate auto-upload account switching and job cancellation
marinofaggiana Aug 31, 2026
d94bf59
fix: retry failed background upload jobs
marinofaggiana Aug 31, 2026
3add952
capabilities fix
marinofaggiana Aug 31, 2026
9168fc4
lint
marinofaggiana Aug 31, 2026
6ad8252
fix: enable background uploads after date changes
marinofaggiana Aug 31, 2026
cf67ba0
fix: disable background upload extension when idle
marinofaggiana Sep 1, 2026
9f84fd4
fix: handle background upload authentication failures
marinofaggiana Sep 1, 2026
c0736d8
fix: handle background upload cancellation
marinofaggiana Sep 1, 2026
182c63d
refactor: improve background upload extension logging
marinofaggiana Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions BackgroundUploadExtension/BackgroundUploadExtension+Accounts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2026 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation
import Photos
import NextcloudKit

extension BackgroundUploadExtension {
func setupAccount() async -> tableAccount? {
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
logDebug("Background upload account setup skipped: Photos authorization is not granted")
return nil
}

guard !NCPreferences().formatCompatibility else {
logDebug("Background upload account setup skipped: compatibility format is enabled")
return nil
}

guard let account = await database.getTableAccountAsync(predicate: NSPredicate(format: "autoUploadStart == true")) else {
logDebug("Background upload account setup skipped: no Auto Upload account")
return nil
}

NextcloudKit.shared.appendSession(
account: account.account,
urlBase: account.urlBase,
user: account.user,
userId: account.userId,
password: NCPreferences().getPassword(account: account.account),
userAgent: userAgent,
httpMaximumConnectionsPerHost: NCBrandOptions.shared.httpMaximumConnectionsPerHost,
httpMaximumConnectionsPerHostInDownload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInDownload,
httpMaximumConnectionsPerHostInUpload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInUpload,
groupIdentifier: NCBrandOptions.shared.capabilitiesGroup
)

guard let capabilities = await database.getCapabilities(account: account.account) else {
logError("Background upload account setup failed: capabilities not found for \(account.account)")
return nil
}

guard NCBrandOptions.shared.isServerVersion(
capabilities,
greaterOrEqualTo: .v33
) else {
logInfo("Background upload extension stopped because account \(account.account) uses a server lower than version 33", persist: true)
return nil
}

return account
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2026 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation
import Photos
import NextcloudKit

extension BackgroundUploadExtension {
func buildDestination(metadata: tableMetadata, asset: PHAsset) -> URLRequest? {
guard let url = metadata.serverUrlFileName.encodedToUrl as? URL else {
logError("Invalid destination URL: \(metadata.serverUrlFileName)")
return nil
}

guard let nkSession = nkComm.nksessions.session(forAccount: metadata.account) else {
logError("Session not found for account: \(metadata.account)")
return nil
}

let wifiOnly = metadata.session == nkComm.identifierSessionUploadBackgroundWWan
let loginString = "\(nkSession.user):\(nkSession.password)"
var request = URLRequest(url: url)

guard let loginData = loginString.data(using: .utf8) else {
logError("Unable to encode credentials for account: \(metadata.account)")
return nil
}

request.httpMethod = "PUT"
request.allowsCellularAccess = !wifiOnly
request.allowsExpensiveNetworkAccess = true
request.setValue(nkSession.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("Basic \(loginData.base64EncodedString())", forHTTPHeaderField: "Authorization")
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("1", forHTTPHeaderField: "X-NC-WebDAV-Auto-Mkcol")

if let creationDate = asset.creationDate,
creationDate.timeIntervalSince1970 > 0 {
request.setValue("\(creationDate.timeIntervalSince1970)", forHTTPHeaderField: "X-OC-CTime")
}

if let modificationDate = asset.modificationDate,
modificationDate.timeIntervalSince1970 > 0 {
request.setValue("\(modificationDate.timeIntervalSince1970)", forHTTPHeaderField: "X-OC-MTime")
}

logDebug("Destination created for \(metadata.fileName) -> \(metadata.serverUrlFileName)")

return request
}
}
222 changes: 222 additions & 0 deletions BackgroundUploadExtension/BackgroundUploadExtension+Discovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2026 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation
import Photos
import NextcloudKit

extension BackgroundUploadExtension {
func createPendingMetadatas(account: tableAccount, limit: Int) async -> Bool {
guard limit > 0,
account.autoUploadImage || account.autoUploadVideo else {
return false
}

let autoUploadServerUrlBase = await database.getAccountAutoUploadServerUrlBaseAsync(
account: account.account,
urlBase: account.urlBase,
userId: account.userId
)

var skipFileNames = await database.fetchSkipFileNamesAsync(account: account.account, autoUploadServerUrlBase: autoUploadServerUrlBase)

var skipAssetLocalIdentifiers = await database.fetchSkipAssetLocalIdentifiersAsync(
account: account.account,
autoUploadServerUrlBase: autoUploadServerUrlBase
)

let fetchOptions = PHFetchOptions()
var mediaPredicates: [NSPredicate] = []

if account.autoUploadImage {
mediaPredicates.append(NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue))
}

if account.autoUploadVideo {
mediaPredicates.append(NSPredicate(format: "mediaType == %d", PHAssetMediaType.video.rawValue))
}

var predicates: [NSPredicate] = [NSCompoundPredicate(orPredicateWithSubpredicates: mediaPredicates)]

if let sinceDate = account.autoUploadSinceDate {
predicates.append(NSPredicate(format: "creationDate >= %@", sinceDate as NSDate))
} else if let lastDate = await database.fetchLastAutoUploadedDateAsync(
account: account.account,
autoUploadServerUrlBase: autoUploadServerUrlBase
) {
predicates.append(NSPredicate(format: "creationDate >= %@", lastDate as NSDate))
}

fetchOptions.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]

var assetsByIdentifier: [String: PHAsset] = [:]

for collection in autoUploadCollections(for: account) {
let assets = PHAsset.fetchAssets(in: collection, options: fetchOptions)

assets.enumerateObjects { asset, _, _ in
assetsByIdentifier[asset.localIdentifier] = asset
}
}

let assets = assetsByIdentifier.values.sorted {
($0.creationDate ?? .distantPast) <
($1.creationDate ?? .distantPast)
}

var remaining = limit
var madeProgress = false
var lastQueuedDate: Date?

for asset in assets {
guard remaining > 0 else {
break
}

guard !skipAssetLocalIdentifiers.contains(asset.localIdentifier) else {
continue
}

guard let resource = primaryUploadResource(for: asset),
let originalFileName = resource.filename,
!originalFileName.isEmpty else {
logError("Upload resource not found for asset \(asset.localIdentifier)")
continue
}

let creationDate = asset.creationDate ?? Date()
let fileName = utilityFileSystem.createFileName(originalFileName, fileDate: creationDate, fileType: asset.mediaType)

guard !skipFileNames.contains(fileName) else {
continue
}

guard await createPendingMetadata(asset: asset, resource: resource, fileName: fileName, account: account) != nil else {
continue
}

skipFileNames.insert(fileName)
skipAssetLocalIdentifiers.insert(asset.localIdentifier)
lastQueuedDate = creationDate
remaining -= 1
madeProgress = true
}

if let lastQueuedDate {
await database.updateAccountPropertyAsync(\.autoUploadSinceDate, value: lastQueuedDate, account: account.account)
}

return madeProgress
}

private func createPendingMetadata(asset: PHAsset, resource: PHAssetResource, fileName: String, account: tableAccount) async -> tableMetadata? {
let session = NCSession.Session(
account: account.account,
urlBase: account.urlBase,
user: account.user,
userId: account.userId
)

let autoUploadServerUrlBase = await database.getAccountAutoUploadServerUrlBaseAsync(
account: account.account,
urlBase: account.urlBase,
userId: account.userId
)

let serverUrl: String
let wifiOnly = asset.mediaType == .image ? account.autoUploadWWAnPhoto : account.autoUploadWWAnVideo

if account.autoUploadCreateSubfolder {
serverUrl = utilityFileSystem.createGranularityPath(asset: asset, serverUrlBase: autoUploadServerUrlBase, granularity: account.autoUploadSubfolderGranularity)
} else {
serverUrl = autoUploadServerUrlBase
}

let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync(
fileName: fileName,
ocId: UUID().uuidString,
serverUrl: serverUrl,
session: session,
sceneIdentifier: nil
)

metadata.assetLocalIdentifier = asset.localIdentifier
metadata.autoUploadServerUrlBase = autoUploadServerUrlBase
metadata.nativeFormat = true
metadata.contentType = resource.contentType.preferredMIMEType ?? "application/octet-stream"
metadata.typeIdentifier = resource.contentType.identifier
metadata.size = Int64(resource.dataSize ?? 0)
metadata.width = asset.pixelWidth
metadata.height = asset.pixelHeight

if let creationDate = asset.creationDate {
metadata.creationDate = creationDate as NSDate
}

if let modificationDate = asset.modificationDate {
metadata.date = modificationDate as NSDate
}

metadata.session = wifiOnly ? nkComm.identifierSessionUploadBackgroundWWan : nkComm.identifierSessionUploadBackground
metadata.sessionSelector = global.selectorUploadAutoUpload
metadata.sessionDate = Date()
metadata.status = global.metadataStatusWaitUpload
metadata.backgroundUploadJobIdentifier = "pending"

await database.addMetadataAsync(metadata)

logDebug("Created pending metadata for \(fileName), account: \(account.account), asset: \(asset.localIdentifier)")

return metadata
}

private func primaryUploadResource(for asset: PHAsset) -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: asset)

switch asset.mediaType {
case .image:
return resources.first {
$0.type == .fullSizePhoto
} ?? resources.first {
$0.type == .photo
}

case .video:
return resources.first {
$0.type == .fullSizeVideo
} ?? resources.first {
$0.type == .video
}

default:
return nil
}
}

private func autoUploadCollections(for account: tableAccount) -> [PHAssetCollection] {
let albumIds = NCPreferences().getAutoUploadAlbumIds(account: account.account)

if !albumIds.isEmpty {
let result = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: Array(albumIds), options: nil)
var collections: [PHAssetCollection] = []

result.enumerateObjects { collection, _, _ in
collections.append(collection)
}

if !collections.isEmpty {
return collections
}
}

let result = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: nil)

guard let cameraRoll = result.firstObject else {
return []
}

return [cameraRoll]
}
}
Loading