Files
noo/ios/Runner/Native/TransferManager.swift
T
Ayushya AmitabhandClaude Sonnet 5.5 02d9d799a6 iOS: authenticate each request by its own header only (no cookies/credential cache)
Two accounts can share one server; the Share Extension's folder listing and
the upload sessions now ignore cookies and stored credentials so one account's
session can never answer for another. Logs each PROPFIND (user, path, status).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:18:20 -04:00

246 lines
9.5 KiB
Swift

import Foundation
import UserNotifications
struct UploadFile {
let uri: String
let name: String
}
/// Uploads and downloads on a background `URLSession`, so a transfer keeps
/// going - and finishes - after the app is suspended or closed. The iOS
/// counterpart of Android's `ShareUploadService`/`DownloadService`
/// foreground services, behind the same Dart channels (`NativeServices`).
///
/// Uploads are one `PUT` per file straight to the account's WebDAV root
/// (no chunking), from a staged copy in Caches - a background upload task
/// needs a file that stays put, and the picker's temp file may not. Downloads
/// land in `Documents/Downloads`, which is visible in the Files app
/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`).
final class TransferManager: NSObject {
static let shared = TransferManager()
static let sessionIdentifier = "dev.ayushya.noo.transfers"
/// Called on the main thread when an upload batch ends with at least one
/// file uploaded: (remote folder, succeeded, failed).
var onUploadBatchFinished: ((String, Int, Int) -> Void)?
/// The system's completion handler from
/// `application(_:handleEventsForBackgroundURLSession:)`, called once the
/// session has delivered every pending event.
var backgroundCompletionHandler: (() -> Void)?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
config.waitsForConnectivity = true
// Two accounts can share a server: authenticate by the request's own
// header only, never a cookie another account's request left behind.
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.urlCredentialStorage = nil
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
/// The session the Share Extension's uploads run on (see `ShareUpload`).
/// The extension is gone by the time they finish, so the app recreates it -
/// same identifier and shared container - to receive the results.
private lazy var shareSession = URLSession(
configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil)
/// Touch both sessions at launch so a system relaunch for finished
/// background transfers reconnects to them.
func reconnect() {
_ = session
_ = shareSession
}
// MARK: - Starting transfers
func startUpload(
serverUrl: String,
username: String,
authHeader: String,
files: [UploadFile],
remoteFolder: String
) throws {
guard !files.isEmpty else { throw TransferError(message: "No files to upload.") }
let batch = UUID().uuidString
let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true)
try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true)
var tasks: [URLSessionUploadTask] = []
for (index, file) in files.enumerated() {
guard let source = URL(string: file.uri), source.isFileURL else {
throw TransferError(message: "Can't read \(file.name).")
}
let staged = stageDir.appendingPathComponent("\(index)-\(file.name)")
do {
try FileManager.default.copyItem(at: source, to: staged)
// A file the Share Extension left in the App Group is ours to clean
// up once it's staged; a picker's temp file isn't.
if source.path.contains("/\(SharedInbox.folderName)/") {
try? FileManager.default.removeItem(at: source)
}
} catch {
throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)")
}
guard let url = WebDAV.fileURL(
serverUrl: serverUrl,
username: username,
remotePath: WebDAV.join(remoteFolder, file.name)
) else {
throw TransferError(message: "Invalid server address.")
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date {
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
}
let task = session.uploadTask(with: request, fromFile: staged)
task.taskDescription = Self.encode(
TransferTaskInfo(
kind: .upload, batch: batch, name: file.name,
remoteFolder: remoteFolder, stagedPath: staged.path
)
)
tasks.append(task)
}
TransferBatchStore.save(
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
id: batch
)
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
tasks.forEach { $0.resume() }
}
func startDownload(
serverUrl: String,
username: String,
authHeader: String,
items: [(path: String, name: String)]
) throws {
guard !items.isEmpty else { throw TransferError(message: "No files to download.") }
let batch = UUID().uuidString
var tasks: [URLSessionDownloadTask] = []
for item in items {
guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else {
throw TransferError(message: "Invalid server address.")
}
var request = URLRequest(url: url)
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
let task = session.downloadTask(with: request)
task.taskDescription = Self.encode(
TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil)
)
tasks.append(task)
}
TransferBatchStore.save(
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
id: batch
)
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
tasks.forEach { $0.resume() }
}
// MARK: - Locations
private static func stagingDirectory() throws -> URL {
let caches = try FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true
)
return caches.appendingPathComponent("uploads", isDirectory: true)
}
static func downloadsDirectory() throws -> URL {
let documents = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
)
let dir = documents.appendingPathComponent("Downloads", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: - Batch bookkeeping
private static func encode(_ info: TransferTaskInfo) -> String? {
(try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) }
}
private static func info(for task: URLSessionTask) -> TransferTaskInfo? {
task.taskDescription
.flatMap { $0.data(using: .utf8) }
.flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }
}
/// Counts one file's outcome; fires the batch summary when it was the last.
private func record(_ info: TransferTaskInfo, success: Bool) {
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
}
private func finish(_ batch: TransferBatch, id: String) {
TransferNotifications.finished(batch, id: id)
reportToDart(batch)
}
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
/// batch has landed at least one file.
private func reportToDart(_ batch: TransferBatch) {
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
}
}
/// Folders the Share Extension finished uploading into while the app wasn't
/// running - call when the app becomes active.
func deliverExtensionUploads() {
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
}
}
}
// MARK: - URLSession delegates
extension TransferManager: URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
) {
guard let info = Self.info(for: downloadTask) else { return }
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
record(info, success: false)
return
}
do {
let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name)
try FileManager.default.moveItem(at: location, to: destination)
record(info, success: true)
} catch {
record(info, success: false)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let info = Self.info(for: task) else { return }
switch info.kind {
case .upload:
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
reportToDart(batch)
}
case .download:
// A finished download was already counted in didFinishDownloadingTo;
// only a transport failure arrives here.
if error != nil { record(info, success: false) }
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
self.backgroundCompletionHandler?()
self.backgroundCompletionHandler = nil
}
}
}