import ExpoModulesCore import Foundation enum DownloadError: Error { case invalidURL case fileOperationFailed case downloadFailed } struct DownloadTaskInfo { let url: String let destinationPath: String? } // Separate delegate class to handle URLSession callbacks class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate { weak var module: BackgroundDownloaderModule? init(module: BackgroundDownloaderModule) { self.module = module super.init() } func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0 print("[BackgroundDownloader] Progress callback: taskId=\(downloadTask.taskIdentifier), written=\(totalBytesWritten), total=\(totalBytesExpectedToWrite), progress=\(progress)") module?.handleProgress( taskId: downloadTask.taskIdentifier, bytesWritten: totalBytesWritten, totalBytes: totalBytesExpectedToWrite ) } func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { print("[BackgroundDownloader] Download finished callback: taskId=\(downloadTask.taskIdentifier)") module?.handleDownloadComplete( taskId: downloadTask.taskIdentifier, location: location, downloadTask: downloadTask ) } func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { print("[BackgroundDownloader] Task completed: taskId=\(task.taskIdentifier), error=\(String(describing: error))") if let httpResponse = task.response as? HTTPURLResponse { print("[BackgroundDownloader] HTTP Status: \(httpResponse.statusCode)") print("[BackgroundDownloader] Content-Length: \(httpResponse.expectedContentLength)") } if let error = error { print("[BackgroundDownloader] Task error: \(error.localizedDescription)") module?.handleError(taskId: task.taskIdentifier, error: error) } } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { DispatchQueue.main.async { if let completion = BackgroundDownloaderModule.backgroundCompletionHandler { completion() BackgroundDownloaderModule.backgroundCompletionHandler = nil } } } } public class BackgroundDownloaderModule: Module { private var session: URLSession? private var sessionDelegate: DownloadSessionDelegate? fileprivate static var backgroundCompletionHandler: (() -> Void)? private var downloadTasks: [Int: DownloadTaskInfo] = [:] public func definition() -> ModuleDefinition { Name("BackgroundDownloader") Events( "onDownloadProgress", "onDownloadComplete", "onDownloadError", "onDownloadStarted" ) OnCreate { self.initializeSession() } AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in guard let url = URL(string: urlString) else { throw DownloadError.invalidURL } if self.session == nil { self.initializeSession() } guard let session = self.session else { throw DownloadError.downloadFailed } // Create a URLRequest to ensure proper handling var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 300 let task = session.downloadTask(with: request) let taskId = task.taskIdentifier print("[BackgroundDownloader] Starting download: taskId=\(taskId), url=\(urlString)") print("[BackgroundDownloader] Destination: \(destinationPath ?? "default")") self.downloadTasks[taskId] = DownloadTaskInfo( url: urlString, destinationPath: destinationPath ) task.resume() print("[BackgroundDownloader] Task resumed with state: \(self.taskStateString(task.state))") print("[BackgroundDownloader] Sending started event") self.sendEvent("onDownloadStarted", [ "taskId": taskId, "url": urlString ]) // Check task state after a brief delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { session.getAllTasks { tasks in if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) { print("[BackgroundDownloader] Task state after 0.5s: \(self.taskStateString(downloadTask.state))") if let response = downloadTask.response as? HTTPURLResponse { print("[BackgroundDownloader] Response status: \(response.statusCode)") print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)") } } } } return taskId } Function("cancelDownload") { (taskId: Int) in self.session?.getAllTasks { tasks in for task in tasks where task.taskIdentifier == taskId { task.cancel() self.downloadTasks.removeValue(forKey: taskId) } } } Function("cancelAllDownloads") { self.session?.getAllTasks { tasks in for task in tasks { task.cancel() } self.downloadTasks.removeAll() } } AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in return try await withCheckedThrowingContinuation { continuation in self.session?.getAllTasks { tasks in let activeDownloads = tasks.compactMap { task -> [String: Any]? in guard task is URLSessionDownloadTask, let info = self.downloadTasks[task.taskIdentifier] else { return nil } return [ "taskId": task.taskIdentifier, "url": info.url, "state": self.taskStateString(task.state) ] } continuation.resume(returning: activeDownloads) } } } } private func initializeSession() { print("[BackgroundDownloader] Initializing URLSession") let config = URLSessionConfiguration.background( withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader" ) config.allowsCellularAccess = true config.sessionSendsLaunchEvents = true config.isDiscretionary = false self.sessionDelegate = DownloadSessionDelegate(module: self) self.session = URLSession( configuration: config, delegate: self.sessionDelegate, delegateQueue: nil ) print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))") } private func taskStateString(_ state: URLSessionTask.State) -> String { switch state { case .running: return "running" case .suspended: return "suspended" case .canceling: return "canceling" case .completed: return "completed" @unknown default: return "unknown" } } // Handler methods called by the delegate func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) { let progress = totalBytes > 0 ? Double(bytesWritten) / Double(totalBytes) : 0.0 print("[BackgroundDownloader] Sending progress event: taskId=\(taskId), progress=\(progress)") self.sendEvent("onDownloadProgress", [ "taskId": taskId, "bytesWritten": bytesWritten, "totalBytes": totalBytes, "progress": progress ]) } func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) { guard let taskInfo = downloadTasks[taskId] else { self.sendEvent("onDownloadError", [ "taskId": taskId, "error": "Download task info not found" ]) return } let fileManager = FileManager.default do { let destinationURL: URL if let customPath = taskInfo.destinationPath { destinationURL = URL(fileURLWithPath: customPath) } else { let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! let filename = downloadTask.response?.suggestedFilename ?? downloadTask.originalRequest?.url?.lastPathComponent ?? "download_\(taskId)" destinationURL = documentsDir.appendingPathComponent(filename) } if fileManager.fileExists(atPath: destinationURL.path) { try fileManager.removeItem(at: destinationURL) } let destinationDirectory = destinationURL.deletingLastPathComponent() if !fileManager.fileExists(atPath: destinationDirectory.path) { try fileManager.createDirectory( at: destinationDirectory, withIntermediateDirectories: true, attributes: nil ) } try fileManager.moveItem(at: location, to: destinationURL) self.sendEvent("onDownloadComplete", [ "taskId": taskId, "filePath": destinationURL.path, "url": taskInfo.url ]) downloadTasks.removeValue(forKey: taskId) } catch { self.sendEvent("onDownloadError", [ "taskId": taskId, "error": "File operation failed: \(error.localizedDescription)" ]) } } func handleError(taskId: Int, error: Error) { let isCancelled = (error as NSError).code == NSURLErrorCancelled if !isCancelled { self.sendEvent("onDownloadError", [ "taskId": taskId, "error": error.localizedDescription ]) } downloadTasks.removeValue(forKey: taskId) } static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) { BackgroundDownloaderModule.backgroundCompletionHandler = handler } }