Files
noo/ios/Runner/Native/TransferManager.swift
T
Ayushya AmitabhandClaude Sonnet 5.5 afc9b90963 iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via
a Keychain group, upload on a background session that outlives the extension
and is finished by the app. The inbox + notification hand-off remains as the
fallback (no account / 'choose later'). Shared code moves to ios/Shared.

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

249 lines
9.4 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
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
)
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
)
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) }
}
private func finish(_ batch: TransferBatch) {
let verb = batch.kind == .upload ? "upload" : "download"
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
let body: String
if batch.failed == 0 {
body = "\(past) \(Self.files(batch.succeeded))"
} else if batch.succeeded == 0 {
body = "Couldn't \(verb) \(Self.files(batch.failed))"
} else {
body = "\(past) \(Self.files(batch.succeeded)), \(batch.failed) failed"
}
notify(body)
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
}
}
private static func files(_ count: Int) -> String {
count == 1 ? "1 file" : "\(count) files"
}
private func notify(_ body: String) {
let content = UNMutableNotificationContent()
content.title = "Noo"
content.body = body
content.sound = .default
UNUserNotificationCenter.current().add(
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
)
}
}
// 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 staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
record(info, success: error == nil && (200..<300).contains(status))
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
}
}
}