diff --git a/ios/EventJournal.swift b/ios/EventJournal.swift new file mode 100644 index 00000000..9f3f2d24 --- /dev/null +++ b/ios/EventJournal.swift @@ -0,0 +1,130 @@ +import Foundation + +// A terminal upload outcome, persisted before it is emitted to JS. +struct JournaledEvent: Codable { + let eventId: String + let id: String // upload id + var type: String // completed | error | cancelled + let timestamp: Double // epoch ms + var responseCode: Int? + var responseBody: String? + var responseBodyTruncated: Bool? + var responseHeaders: [String: String]? + var error: String? + var errorKind: String? // http | network | file | unknown + var cancelReason: String? // user | system + + // Bridge-friendly dictionary (nil fields omitted so nothing becomes NSNull). + var bridged: [String: Any] { + var m: [String: Any] = ["eventId": eventId, "id": id, "type": type, "timestamp": timestamp] + if let responseCode { m["responseCode"] = responseCode } + if let responseBody { m["responseBody"] = responseBody } + if let responseBodyTruncated { m["responseBodyTruncated"] = responseBodyTruncated } + if let responseHeaders { m["responseHeaders"] = responseHeaders } + if let error { m["error"] = error } + if let errorKind { m["errorKind"] = errorKind } + if let cancelReason { m["cancelReason"] = cancelReason } + return m + } +} + +// Durable record of terminal upload events (completed / error / cancelled). +// Written BEFORE the event is emitted to JS, deleted only when JS acknowledges, +// so an outcome that fires while JS is dead survives to the next launch. +// +// Synchronous (serial queue) — deliberately NOT an actor. The URLSession delegate +// is synchronous and must journal an outcome BEFORE emitting it; an actor would +// force that ordering to become async and racy. +// +// One JSON file per event: Data.write(atomically:) is its own tmp+rename, so a +// crash mid-write can't corrupt other entries, and separate files avoid a shared +// mutable file across processes. +enum EventJournal { + static let maxBodyChars = 64 * 1024 + static let maxEntries = 1000 + + private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.journal") + + // Char-count cap (a byte-accurate split could cut a surrogate pair). Single + // source of truth so the journaled body and the live-emitted body match. + static func capBody(_ body: String?) -> (String?, Bool) { + guard let body, body.count > maxBodyChars else { return (body, false) } + return (String(body.prefix(maxBodyChars)), true) + } + + // Computed once: creating the dir and re-setting the backup flag on every + // append/read/ack call is wasteful. + private static let dirURL: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + var dir = base.appendingPathComponent("RNFileUploaderEvents", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Transient device-local state; keep it out of iCloud/iTunes backups. + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? dir.setResourceValues(values) + return dir + }() + + static func append(_ event: JournaledEvent) { + queue.sync { + var e = event + let (body, truncated) = capBody(e.responseBody) + if truncated { + e.responseBody = body + e.responseBodyTruncated = true + } + // A journal write must never throw into the caller: the delegate calls this + // right after a completed upload, and a propagated failure could misfire the + // error path. Dropping one entry is the lesser evil. + guard let data = try? JSONEncoder().encode(e) else { return } + do { + try data.write(to: dirURL.appendingPathComponent("\(e.eventId).json"), options: .atomic) + } catch { + NSLog("[RNFileUploader] journal append failed: \(error.localizedDescription)") + return + } + pruneToMax() + } + } + + static func unacknowledged() -> [[String: Any]] { + queue.sync { + let files = (try? FileManager.default.contentsOfDirectory(at: dirURL, includingPropertiesForKeys: nil)) ?? [] + return files + .filter { $0.pathExtension == "json" } + .compactMap { url -> JournaledEvent? in + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(JournaledEvent.self, from: data) + } + .sorted { $0.timestamp < $1.timestamp } + .map { $0.bridged } + } + } + + static func ack(_ eventIds: [String]) { + queue.sync { + for id in eventIds { + try? FileManager.default.removeItem(at: dirURL.appendingPathComponent("\(id).json")) + } + } + } + + // Runaway guard: assumes JS drains via ack on each boot, but bounds the + // directory if that loop breaks or hasn't been adopted. Drops the oldest by + // file modification time (no parsing). Caller already holds `queue`. + private static func pruneToMax() { + let key: URLResourceKey = .contentModificationDateKey + guard let files = try? FileManager.default.contentsOfDirectory( + at: dirURL, includingPropertiesForKeys: [key]) else { return } + let jsons = files.filter { $0.pathExtension == "json" } + guard jsons.count > maxEntries else { return } + let sorted = jsons.sorted { + let a = (try? $0.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast + let b = (try? $1.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast + return a < b + } + for f in sorted.prefix(jsons.count - maxEntries) { + try? FileManager.default.removeItem(at: f) + } + } +} diff --git a/ios/Helper.h b/ios/Helper.h deleted file mode 100644 index 97aba675..00000000 --- a/ios/Helper.h +++ /dev/null @@ -1,8 +0,0 @@ -#import - -@interface Helper: NSObject {} - -+ (NSString *) urlSessionTaskStateToString: (NSURLSessionTaskState)state; - - -@end diff --git a/ios/Helper.m b/ios/Helper.m deleted file mode 100644 index b897ad59..00000000 --- a/ios/Helper.m +++ /dev/null @@ -1,25 +0,0 @@ -#import - -#import "Helper.h" - -@implementation Helper - - - -+ (NSString *) urlSessionTaskStateToString: (NSURLSessionTaskState)state { - switch (state) { - case NSURLSessionTaskStateRunning: - return @"running"; - case NSURLSessionTaskStateSuspended: - return @"suspended"; - case NSURLSessionTaskStateCompleted: - return @"completed"; - case NSURLSessionTaskStateCanceling: - return @"canceling"; - default: - return NULL; - } -} - -@end - diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift new file mode 100644 index 00000000..2aa64764 --- /dev/null +++ b/ios/RNBackgroundUpload.swift @@ -0,0 +1,481 @@ +import Foundation +import React + +// Live events destined for JS. The TurboModule shell (RNFileUploader.mm) adopts +// this and forwards to the codegen-generated emitters. The delegate is nil +// whenever JS isn't around (headless relaunch, before the module is created, +// after a reload tears the old one down) — terminal outcomes are journaled +// before we ever get here, so dropping a live event is always safe. +@objc public protocol RNFileUploaderEventDelegate { + func emitProgress(_ body: [String: Any]) + func emitCompleted(_ body: [String: Any]) + func emitError(_ body: [String: Any]) + func emitCancelled(_ body: [String: Any]) +} + +// Background HTTP file uploader (iOS). Uploads run on a background URLSession so +// they continue while the app is suspended and complete/relaunch when terminated +// by the system. Terminal outcomes are journaled before being emitted, so JS can +// recover them even if it was dead when they fired. +// +// State that must be consistent for the whole process is STATIC: the background +// sessions, the in-flight response buffers, the user-cancel set, and the event +// delegate. The TurboModule instance comes and goes with the JS runtime while the +// URLSession delegate stays pinned to this object, so keeping that state static +// (rather than on the module) is what keeps cancel attribution and response +// assembly correct across a reload — and guarantees we never create two +// background sessions with the same identifier. +@objc(RNBackgroundUpload) +public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { + + // The instance that owns the URLSession delegate callbacks. Created on first + // access — by the TurboModule, or by the AppDelegate's + // handleEventsForBackgroundURLSession hook, whichever happens first. That + // second path is load-bearing: on a system relaunch there may be no JS at all, + // and touching `shared` is what recreates the sessions so nsurlsessiond can + // deliver the delegate events it has queued for us. + @objc public static let shared = RNBackgroundUpload() + + private static let backgroundSessionId = "ReactNativeBackgroundUpload" + private static let wifiOnlySessionId = "ReactNativeBackgroundUpload_WifiOnly" + private static let progressThrottle: TimeInterval = 0.5 // seconds, per upload + + private static let lock = NSLock() + private static var responsesData: [String: NSMutableData] = [:] // sessionId:taskId -> body + private static var lastProgressAt: [String: TimeInterval] = [:] // uploadId -> time + private static var userCancelledIds = Set() + + private static var backgroundSession: URLSession? + private static var wifiOnlySession: URLSession? + + // Deliberately its own lock, not `lock`: creating `shared` acquires `lock` to + // build the sessions, so guarding the delegate with the same lock would risk a + // deadlock between "ensure shared exists" and "set the delegate". + private static let delegateLock = NSLock() + private static weak var eventDelegate: RNFileUploaderEventDelegate? + + // AppDelegate stores the system-provided completion handler here (per session + // id) so the app can be relaunched to finish uploads after termination. + private static let bgHandlerLock = NSLock() + private static var bgCompletionHandlers: [String: () -> Void] = [:] + + public override init() { + super.init() + // Recreate the sessions as early as possible so delegate events queued by + // nsurlsessiond from a previous launch are delivered to this process. + _ = session(wifiOnly: false) + _ = session(wifiOnly: true) + } + + // MARK: - Event delegate + + @objc public static func setEventDelegate(_ delegate: RNFileUploaderEventDelegate) { + // Force the singleton (and therefore the sessions) into existence before + // taking the lock — see the note on delegateLock. + _ = shared + delegateLock.lock() + eventDelegate = delegate + delegateLock.unlock() + } + + /// Deregisters a delegate, but only if it is still the registered one. + /// + /// React Native dispatches `invalidate` asynchronously and gives up waiting + /// after 10s, so a slow call can let the replacement module register itself + /// before the outgoing module's `invalidate` actually runs. Clearing + /// unconditionally there would null out the live delegate and silently stop + /// every event for the rest of the process. + @objc public static func clearEventDelegate(_ delegate: RNFileUploaderEventDelegate) { + delegateLock.lock() + defer { delegateLock.unlock() } + if eventDelegate === delegate { eventDelegate = nil } + } + + private static var currentDelegate: RNFileUploaderEventDelegate? { + delegateLock.lock() + defer { delegateLock.unlock() } + return eventDelegate + } + + // MARK: - Sessions + + private func session(wifiOnly: Bool) -> URLSession { + RNBackgroundUpload.lock.lock() + defer { RNBackgroundUpload.lock.unlock() } + if wifiOnly { + if let s = RNBackgroundUpload.wifiOnlySession { return s } + let s = makeSession(identifier: RNBackgroundUpload.wifiOnlySessionId, wifiOnly: true) + RNBackgroundUpload.wifiOnlySession = s + return s + } else { + if let s = RNBackgroundUpload.backgroundSession { return s } + let s = makeSession(identifier: RNBackgroundUpload.backgroundSessionId, wifiOnly: false) + RNBackgroundUpload.backgroundSession = s + return s + } + } + + // Session configuration is load-bearing and carried over verbatim from the + // original Obj-C. Config must be set before the session is created (URLSession + // copies it). Background upload tasks require uploadTask(with:fromFile:). + private func makeSession(identifier: String, wifiOnly: Bool) -> URLSession { + let config = URLSessionConfiguration.background(withIdentifier: identifier) + config.isDiscretionary = false + config.httpMaximumConnectionsPerHost = 1 + config.waitsForConnectivity = true + config.allowsCellularAccess = !wifiOnly + config.allowsConstrainedNetworkAccess = !wifiOnly + config.allowsExpensiveNetworkAccess = !wifiOnly + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + private func taskMapKey(_ session: URLSession, _ task: URLSessionTask) -> String { + "\(session.configuration.identifier ?? ""):\(task.taskIdentifier)" + } + + // taskDescription is the primary id; the persisted map is the durable fallback. + private func uploadId(_ session: URLSession, _ task: URLSessionTask) -> String { + task.taskDescription ?? TaskMap.meta(forKey: taskMapKey(session, task))?.id ?? "unknown" + } + + private func acceptStatus(_ session: URLSession, _ task: URLSessionTask) -> [Int] { + TaskMap.meta(forKey: taskMapKey(session, task))?.acceptStatus ?? [] + } + + private var activeSessions: [URLSession] { + [RNBackgroundUpload.backgroundSession, RNBackgroundUpload.wifiOnlySession].compactMap { $0 } + } + + // MARK: - Exported methods (called from the TurboModule shell) + + @objc(startUpload:resolve:reject:) + public func startUpload(_ options: [String: Any], + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let urlString = options["url"] as? String, let path = options["path"] as? String else { + reject("RN Uploader", "Missing 'url' or 'path'", nil); return + } + guard let requestUrl = URL(string: urlString) else { + reject("RN Uploader", "URL not compliant with RFC 2396", nil); return + } + let type = (options["type"] as? String) ?? "raw" + if type != "raw" { + reject("RN Uploader", "Only type: 'raw' is supported", nil); return + } + + var request = URLRequest(url: requestUrl) + request.httpMethod = (options["method"] as? String) ?? "POST" + if let headers = options["headers"] as? [String: Any] { + for (key, value) in headers { + // Only strings and numbers become headers. The original Obj-C skipped + // anything else, and interpolating instead would put "" (or a + // Swift struct description) on the wire for a null/object value — + // silently corrupting e.g. an Authorization header rather than omitting it. + if let s = value as? String { + request.setValue(s, forHTTPHeaderField: key) + } else if let n = value as? NSNumber { + request.setValue(n.stringValue, forHTTPHeaderField: key) + } + } + } + + let wifiOnly = (options["wifiOnly"] as? Bool) ?? false + // RN bridges a JS number[] to NSArray; map explicitly rather than + // rely on an [Int] bridging cast that can yield nil and silently drop it. + let acceptStatus = (options["acceptStatus"] as? [NSNumber])?.map { $0.intValue } ?? [] + let uploadId = (options["customUploadId"] as? String) ?? UUID().uuidString + let fileURL = URL(string: path) ?? URL(fileURLWithPath: path) + + let session = self.session(wifiOnly: wifiOnly) + let task = session.uploadTask(with: request, fromFile: fileURL) + task.taskDescription = uploadId + TaskMap.set(TaskMap.Meta(id: uploadId, acceptStatus: acceptStatus), + forKey: taskMapKey(session, task)) + task.resume() + resolve(uploadId) + } + + @objc(cancelUpload:resolve:reject:) + public func cancelUpload(_ cancelUploadId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + // Record intent before cancelling so the delegate reports cancelReason 'user'. + RNBackgroundUpload.lock.lock() + RNBackgroundUpload.userCancelledIds.insert(cancelUploadId) + RNBackgroundUpload.lock.unlock() + + let sessions = activeSessions + let group = DispatchGroup() + // Guarded: the two sessions' getAllTasks completions run on independent + // delegate queues, so this is written concurrently. + let foundLock = NSLock() + var found = false + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks where self.uploadId(session, task) == cancelUploadId { + foundLock.lock() + found = true + foundLock.unlock() + task.cancel() + } + group.leave() + } + } + group.notify(queue: .main) { + foundLock.lock() + let matched = found + foundLock.unlock() + if !matched { + // Nothing to cancel: drop the intent again so a later upload reusing this + // customUploadId isn't misattributed as a user cancel. + RNBackgroundUpload.lock.lock() + RNBackgroundUpload.userCancelledIds.remove(cancelUploadId) + RNBackgroundUpload.lock.unlock() + } + resolve(matched) + } + } + + @objc(getUploadStatus:resolve:reject:) + public func getUploadStatus(_ uploadId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + let sessions = activeSessions + let group = DispatchGroup() + let lock = NSLock() + var result: [String: Any]? + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks where self.uploadId(session, task) == uploadId { + lock.lock() + if result == nil { + result = ["state": self.stateString(task.state), + "bytesSent": task.countOfBytesSent, + "totalBytes": task.countOfBytesExpectedToSend] + } + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .main) { resolve(result) } + } + + @objc(getUnacknowledgedEvents:reject:) + public func getUnacknowledgedEvents(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + resolve(EventJournal.unacknowledged()) + } + + @objc(ackEvents:resolve:reject:) + public func ackEvents(_ eventIds: [String], + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + EventJournal.ack(eventIds) + resolve(true) + } + + @objc(getAllUploads:reject:) + public func getAllUploads(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + let sessions = activeSessions + let group = DispatchGroup() + let lock = NSLock() + var result: [[String: Any]] = [] + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks { + let id = self.uploadId(session, task) + if id == "unknown" { continue } + lock.lock() + // Report the real state. Collapsing everything non-running into + // "pending" told a consumer's boot reconciliation that an upload had + // never started, inviting it to re-enqueue one that was already + // finishing or cancelling. + let state: String + switch task.state { + case .running: state = "running" + case .suspended: state = "pending" + case .canceling: state = "cancelled" + case .completed: state = "completed" + @unknown default: state = "pending" + } + result.append(["id": id, + "state": state, + "bytesSent": task.countOfBytesSent, + "totalBytes": task.countOfBytesExpectedToSend]) + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .main) { resolve(result) } + } + + // Called from AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:). + // Reachable from a consumer's plain Obj-C via `@import + // react_native_background_upload;` — deliberately NOT on the TurboModule class, + // whose header is Obj-C++ only. + @objc(setBackgroundSessionCompletionHandler:forIdentifier:) + public static func setBackgroundSessionCompletionHandler(_ handler: @escaping () -> Void, + forIdentifier identifier: String) { + // Touching `shared` recreates the background sessions when this is a fresh, + // system-relaunched process, which is what lets the queued delegate events + // (and therefore this handler) actually fire. + _ = shared + bgHandlerLock.lock() + bgCompletionHandlers[identifier] = handler + bgHandlerLock.unlock() + } + + // MARK: - URLSession delegate + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard !data.isEmpty else { return } + // Key by sessionId:taskId, not taskIdentifier alone: taskIdentifier is unique + // per session, so two concurrent uploads (one wifiOnly, one not) can share an + // identifier and would otherwise cross-contaminate response bodies. + let key = taskMapKey(session, dataTask) + RNBackgroundUpload.lock.lock() + if let existing = RNBackgroundUpload.responsesData[key] { + existing.append(data) + } else { + RNBackgroundUpload.responsesData[key] = NSMutableData(data: data) + } + RNBackgroundUpload.lock.unlock() + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + didSendBodyData bytesSent: Int64, totalBytesSent: Int64, + totalBytesExpectedToSend: Int64) { + // 0 rather than -1 when the length is unknown: the documented range is + // 0-100, Android reports 0 for the same case, and a negative value renders + // as a broken progress bar in a consumer that passes it straight through. + var progress: Float = 0 + if totalBytesExpectedToSend > 0 { + progress = 100.0 * Float(totalBytesSent) / Float(totalBytesExpectedToSend) + } + let id = uploadId(session, task) + let now = Date().timeIntervalSince1970 + RNBackgroundUpload.lock.lock() + if progress < 100, + let last = RNBackgroundUpload.lastProgressAt[id], + now - last < RNBackgroundUpload.progressThrottle { + RNBackgroundUpload.lock.unlock() + return + } + RNBackgroundUpload.lastProgressAt[id] = now + RNBackgroundUpload.lock.unlock() + RNBackgroundUpload.currentDelegate?.emitProgress(["id": id, "progress": progress]) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let id = uploadId(session, task) + let http = task.response as? HTTPURLResponse + let statusCode = http?.statusCode ?? 0 + + var headers: [String: String] = [:] + if let http { + for (key, value) in http.allHeaderFields { headers["\(key)"] = "\(value)" } + } + + RNBackgroundUpload.lock.lock() + let bodyData = RNBackgroundUpload.responsesData.removeValue(forKey: taskMapKey(session, task)) + RNBackgroundUpload.lastProgressAt[id] = nil + // Consume the user-cancel intent on EVERY terminal outcome, not only the + // cancelled branch. If cancelUpload lost the race with completion, the id + // would otherwise linger for the life of the process and a later upload + // reusing that customUploadId would report a system cancel as a user cancel. + let userCancelled = RNBackgroundUpload.userCancelledIds.remove(id) != nil + RNBackgroundUpload.lock.unlock() + + let rawBody = bodyData.flatMap { String(data: $0 as Data, encoding: .utf8) } ?? "" + let (cappedBody, truncated) = EventJournal.capBody(rawBody) + let responseBody = cappedBody ?? "" + + let eventId = UUID().uuidString + let timestamp = Date().timeIntervalSince1970 * 1000 + var event = JournaledEvent(eventId: eventId, id: id, type: "completed", timestamp: timestamp) + if http != nil { + event.responseCode = statusCode + event.responseHeaders = headers + event.responseBody = responseBody + event.responseBodyTruncated = truncated + } + + if error == nil { + // "completed" only for 2xx or a per-request acceptStatus code; any other + // HTTP response is a terminal http error carrying the full response. + let accepted = (200..<300).contains(statusCode) || acceptStatus(session, task).contains(statusCode) + if accepted { + event.type = "completed" + } else { + event.type = "error" + event.errorKind = "http" + event.error = "HTTP \(statusCode)" + } + } else { + let nsError = error! as NSError + if nsError.code == NSURLErrorCancelled { + event.type = "cancelled" + event.cancelReason = userCancelled ? "user" : "system" + } else { + event.type = "error" + event.errorKind = RNBackgroundUpload.errorKind(for: nsError) + event.error = nsError.localizedDescription + } + } + + // Journal BEFORE emitting; the emit is best-effort (JS may be dead). + EventJournal.append(event) + TaskMap.removeKey(taskMapKey(session, task)) + + let body = event.bridged + let delegate = RNBackgroundUpload.currentDelegate + switch event.type { + case "completed": delegate?.emitCompleted(body) + case "cancelled": delegate?.emitCancelled(body) + default: delegate?.emitError(body) + } + } + + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + guard let identifier = session.configuration.identifier else { return } + RNBackgroundUpload.bgHandlerLock.lock() + let handler = RNBackgroundUpload.bgCompletionHandlers.removeValue(forKey: identifier) + RNBackgroundUpload.bgHandlerLock.unlock() + if let handler { DispatchQueue.main.async { handler() } } + } + + // Classify a transport error to match Android's errorKind taxonomy: a missing or + // unreadable source file -> 'file'; other URL-domain errors -> 'network'; anything + // else -> 'unknown'. + private static func errorKind(for error: NSError) -> String { + switch (error.domain, error.code) { + case (NSURLErrorDomain, NSURLErrorFileDoesNotExist), + (NSURLErrorDomain, NSURLErrorCannotOpenFile), + (NSURLErrorDomain, NSURLErrorNoPermissionsToReadFile), + (NSCocoaErrorDomain, NSFileNoSuchFileError), + (NSCocoaErrorDomain, NSFileReadNoSuchFileError), + (NSCocoaErrorDomain, NSFileReadNoPermissionError): + return "file" + case (NSURLErrorDomain, _): + return "network" + default: + return "unknown" + } + } + + private func stateString(_ 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 "running" + } + } +} diff --git a/ios/RNFileUploader.h b/ios/RNFileUploader.h new file mode 100644 index 00000000..2f49cd5f --- /dev/null +++ b/ios/RNFileUploader.h @@ -0,0 +1,12 @@ +#import + +// TurboModule shell (New Architecture). All upload behaviour lives in +// RNBackgroundUpload.swift; this class only adapts the codegen-generated spec to +// it and forwards events to the generated emitters. +// +// This header is Obj-C++ ONLY — the generated spec header above #errors in plain +// Obj-C — so the podspec marks it private. It must never reach a consumer's .m +// translation unit. Consumers that need the background-session handler import the +// Swift class instead (`@import react_native_background_upload;`). +@interface RNFileUploader : NativeRNFileUploaderSpecBase +@end diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm new file mode 100644 index 00000000..0ce76070 --- /dev/null +++ b/ios/RNFileUploader.mm @@ -0,0 +1,152 @@ +#import "RNFileUploader.h" + +// Both spellings are needed to support use_frameworks! as well as static linking. +#if __has_include("react_native_background_upload-Swift.h") +#import "react_native_background_upload-Swift.h" +#else +#import +#endif + +#include + +@interface RNFileUploader () +@end + +@implementation RNFileUploader + +- (instancetype)init +{ + self = [super init]; + if (self) { + // Also forces RNBackgroundUpload.shared into existence, which recreates the + // background URLSessions for this process. + [RNBackgroundUpload setEventDelegate:self]; + } + return self; +} + +- (void)invalidate +{ + // Identity-checked: React Native dispatches invalidate asynchronously and + // stops waiting after 10s, so the replacement module can register itself + // first. Clearing unconditionally would kill events for the whole process. + [RNBackgroundUpload clearEventDelegate:self]; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// Without this, RN hands the module its process-wide shared TurboModule queue, +// where our synchronous journal and task-map disk I/O would stall unrelated +// native modules — and RN's own invalidate, which queues behind it. The legacy +// bridge gave every module its own queue; a TurboModule has to ask. +- (dispatch_queue_t)methodQueue +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("ai.openspace.rnbgupload.module", DISPATCH_QUEUE_SERIAL); + }); + + return queue; +} + ++ (NSString *)moduleName +{ + return @"RNFileUploader"; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +#pragma mark - Exported methods + +- (void)startUpload:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared startUpload:options resolve:resolve reject:reject]; +} + +- (void)cancelUpload:(NSString *)id + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared cancelUpload:id resolve:resolve reject:reject]; +} + +- (void)getUploadStatus:(NSString *)id + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared getUploadStatus:id resolve:resolve reject:reject]; +} + +- (void)getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared getUnacknowledgedEvents:resolve reject:reject]; +} + +- (void)ackEvents:(NSArray *)ids + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared ackEvents:ids resolve:resolve reject:reject]; +} + +- (void)getAllUploads:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [RNBackgroundUpload.shared getAllUploads:resolve reject:reject]; +} + +#pragma mark - RNFileUploaderEventDelegate + +// Called synchronously on the URLSession delegate queue. That is safe and +// deliberate: the generated emitter locks its own state and dispatches each +// listener through the JS CallInvoker, so it is already thread-safe and already +// async onto the JS thread. Deferring to the main queue instead would open a +// window where the module's TurboModule is torn down before the block runs. +// +// The try/catch is required, not defensive: the generated emitOnX calls an +// std::function that is only installed when the TurboModule is constructed, +// which happens after this module's init has already registered as the delegate. +// An event in that gap throws std::bad_function_call, as does one emitted with no +// JS listeners attached. Both are harmless — the terminal outcome is already in +// the journal, so JS recovers it from getUnacknowledgedEvents. +- (void)safeEmit:(void (^)(RNFileUploader *emitter))block +{ + try { + block(self); + } catch (const std::exception &e) { + // No listeners yet, or the runtime is gone — drop the live event. + } +} + +- (void)emitProgress:(NSDictionary *)body +{ + [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnProgress:body]; }]; +} + +- (void)emitCompleted:(NSDictionary *)body +{ + [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCompleted:body]; }]; +} + +- (void)emitError:(NSDictionary *)body +{ + [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnError:body]; }]; +} + +- (void)emitCancelled:(NSDictionary *)body +{ + [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCancelled:body]; }]; +} + +@end diff --git a/ios/TaskMap.swift b/ios/TaskMap.swift new file mode 100644 index 00000000..195b2a0a --- /dev/null +++ b/ios/TaskMap.swift @@ -0,0 +1,56 @@ +import Foundation + +// Durable ":" -> { id, acceptStatus } mapping. +// +// Apple documents `taskDescription` only as an uninterpreted app string with no +// guarantee it survives process death, and DTS guidance is to persist task +// metadata externally keyed by the (stable) taskIdentifier. taskDescription +// stays the primary id; this map is the durable fallback so a task observed +// after relaunch is never orphaned under an unknown id, and so acceptStatus is +// still known when a task completes after the original startUpload options are gone. +// +// Synchronous serial-queue access; a single JSON file. +enum TaskMap { + struct Meta: Codable { + let id: String + let acceptStatus: [Int] + } + + private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.taskmap") + + private static var fileURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent("RNFileUploaderTaskMap.json") + } + + static func set(_ meta: Meta, forKey key: String) { + queue.sync { + var map = read() + map[key] = meta + write(map) + } + } + + static func meta(forKey key: String) -> Meta? { + queue.sync { read()[key] } + } + + static func removeKey(_ key: String) { + queue.sync { + var map = read() + map.removeValue(forKey: key) + write(map) + } + } + + private static func read() -> [String: Meta] { + guard let data = try? Data(contentsOf: fileURL) else { return [:] } + return (try? JSONDecoder().decode([String: Meta].self, from: data)) ?? [:] + } + + private static func write(_ map: [String: Meta]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/ios/VydiaRNFileUploader.m b/ios/VydiaRNFileUploader.m deleted file mode 100644 index a6021683..00000000 --- a/ios/VydiaRNFileUploader.m +++ /dev/null @@ -1,445 +0,0 @@ -#import -#import -#import -#import -#import -#import "Helper.h" - -@interface VydiaRNFileUploader : RCTEventEmitter -@end - -@implementation VydiaRNFileUploader - -RCT_EXPORT_MODULE(); - -@synthesize bridge = _bridge; -static int uploadId = 0; -static RCTEventEmitter* staticEventEmitter = nil; -static NSString *BACKGROUND_SESSION_ID = @"ReactNativeBackgroundUpload"; -static NSString *WIFI_ONLY_BACKGROUND_SESSION_ID = @"ReactNativeBackgroundUpload_WifiOnly"; - -NSURLSession *_urlSession = nil; -NSURLSession *_wifiOnlyUrlSession = nil; -NSMutableDictionary *_responsesData = nil; - -+ (BOOL)requiresMainQueueSetup { - return NO; -} - --(id) init { - self = [super init]; - if(!self) return self; - - staticEventEmitter = self; - _responsesData = [NSMutableDictionary dictionary]; - - // Initializes as early as possible to receive delegate events - // sent from previously registered URLSessions - [self urlSession]; - [self wifiOnlyUrlSession]; - return self; -} - -// This method prevents crashing in development after a JS reload. -// The reason for that is self is being sent to the operating system as delegate. -// After a JS reload, the old self still sticks around accepting events and -// sending the events through an old instance of the RN app. This crashes the app. -// That's why we need to use a static variable to reference the latest instance of self, -// which references the latest instance of the RN app. -- (void)_sendEventWithName:(NSString *)eventName body:(id)body { - if (!staticEventEmitter) return; - [staticEventEmitter sendEventWithName:eventName body:body]; -} - -- (NSArray *)supportedEvents { - return @[ - @"RNFileUploader-progress", - @"RNFileUploader-error", - @"RNFileUploader-cancelled", - @"RNFileUploader-completed" - ]; -} - - -/* - Borrowed from http://stackoverflow.com/questions/2439020/wheres-the-iphone-mime-type-database - */ -- (NSString *)guessMIMETypeFromFileName: (NSString *)fileName { - CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[fileName pathExtension], NULL); - CFStringRef MIMEType = UTTypeCopyPreferredTagWithClass(UTI, kUTTagClassMIMEType); - - if (UTI) { - CFRelease(UTI); - } - - if (!MIMEType) { - return @"application/octet-stream"; - } - return (__bridge NSString *)(MIMEType); -} - -/* - Utility method to copy a PHAsset file into a local temp file, which can then be uploaded. - */ -- (void)copyAssetToFile: (NSString *)assetUrl completionHandler: (void(^)(NSString *__nullable tempFileUrl, NSError *__nullable error))completionHandler { - NSURL *url = [NSURL URLWithString:assetUrl]; - PHAsset *asset = [PHAsset fetchAssetsWithALAssetURLs:@[url] options:nil].lastObject; - if (!asset) { - NSMutableDictionary* details = [NSMutableDictionary dictionary]; - [details setValue:@"Asset could not be fetched. Are you missing permissions?" forKey:NSLocalizedDescriptionKey]; - completionHandler(nil, [NSError errorWithDomain:@"RNUploader" code:5 userInfo:details]); - return; - } - PHAssetResource *assetResource = [[PHAssetResource assetResourcesForAsset:asset] firstObject]; - NSString *pathToWrite = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; - NSURL *pathUrl = [NSURL fileURLWithPath:pathToWrite]; - NSString *fileURI = pathUrl.absoluteString; - - PHAssetResourceRequestOptions *options = [PHAssetResourceRequestOptions new]; - options.networkAccessAllowed = YES; - - [[PHAssetResourceManager defaultManager] writeDataForAssetResource:assetResource toFile:pathUrl options:options completionHandler:^(NSError * _Nullable e) { - if (e == nil) { - completionHandler(fileURI, nil); - } - else { - completionHandler(nil, e); - } - }]; -} - -/* - * Starts a file upload. - * Options are passed in as the first argument as a js hash: - * { - * url: string. url to post to. - * path: string. path to the file on the device - * headers: hash of name/value header pairs - * } - * - * Returns a promise with the string ID of the upload. - */ -RCT_EXPORT_METHOD(startUpload:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -{ - int thisUploadId; - @synchronized(self.class) - { - thisUploadId = uploadId++; - } - - NSString *uploadUrl = options[@"url"]; - __block NSString *fileURI = options[@"path"]; - NSString *method = options[@"method"] ?: @"POST"; - NSString *uploadType = options[@"type"] ?: @"raw"; - NSString *fieldName = options[@"field"]; - NSString *customUploadId = options[@"customUploadId"]; - NSString *appGroup = options[@"appGroup"]; - NSDictionary *headers = options[@"headers"]; - NSDictionary *parameters = options[@"parameters"]; - BOOL wifiOnly = [options[@"wifiOnly"] boolValue]; - - @try { - NSURL *requestUrl = [NSURL URLWithString: uploadUrl]; - if (requestUrl == nil) { - return reject(@"RN Uploader", @"URL not compliant with RFC 2396", nil); - } - - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:requestUrl]; - [request setHTTPMethod: method]; - - [headers enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull val, BOOL * _Nonnull stop) { - if ([val respondsToSelector:@selector(stringValue)]) { - val = [val stringValue]; - } - if ([val isKindOfClass:[NSString class]]) { - [request setValue:val forHTTPHeaderField:key]; - } - }]; - - - // asset library files have to be copied over to a temp file. they can't be uploaded directly - if ([fileURI hasPrefix:@"assets-library"]) { - dispatch_group_t group = dispatch_group_create(); - dispatch_group_enter(group); - [self copyAssetToFile:fileURI completionHandler:^(NSString * _Nullable tempFileUrl, NSError * _Nullable error) { - if (error) { - dispatch_group_leave(group); - reject(@"RN Uploader", @"Asset could not be copied to temp file.", nil); - return; - } - fileURI = tempFileUrl; - dispatch_group_leave(group); - }]; - dispatch_group_wait(group, DISPATCH_TIME_FOREVER); - } - - NSURLSessionDataTask *uploadTask; - - NSURLSession *session = wifiOnly ? [self wifiOnlyUrlSession] : [self urlSession]; - if (appGroup != nil && ![appGroup isEqualToString:@""]) { - session.configuration.sharedContainerIdentifier = appGroup; - } - - if ([uploadType isEqualToString:@"multipart"]) { - NSString *uuidStr = [[NSUUID UUID] UUIDString]; - [request setValue:[NSString stringWithFormat:@"multipart/form-data; boundary=%@", uuidStr] forHTTPHeaderField:@"Content-Type"]; - - NSData *httpBody = [self createBodyWithBoundary:uuidStr path:fileURI parameters: parameters fieldName:fieldName]; - [request setHTTPBodyStream: [NSInputStream inputStreamWithData:httpBody]]; - [request setValue:[NSString stringWithFormat:@"%zd", httpBody.length] forHTTPHeaderField:@"Content-Length"]; - - uploadTask = [session uploadTaskWithStreamedRequest:request]; - } else { - if (parameters.count > 0) { - reject(@"RN Uploader", @"Parameters supported only in multipart type", nil); - return; - } - - uploadTask = [session uploadTaskWithRequest:request fromFile:[NSURL URLWithString: fileURI]]; - } - - uploadTask.taskDescription = customUploadId ? customUploadId : [NSString stringWithFormat:@"%i", thisUploadId]; - - [uploadTask resume]; - resolve(uploadTask.taskDescription); - } - @catch (NSException *exception) { - if(exception.reason) - reject(@"RN Uploader", exception.reason, nil); - else - reject(@"RN Uploader", exception.name, nil); - } -} - -/* - * Cancels file upload - * Accepts upload ID as a first argument, this upload will be cancelled - * Event "cancelled" will be fired when upload is cancelled. - */ -RCT_EXPORT_METHOD(cancelUpload: (NSString *)cancelUploadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSMutableArray *sessions = [NSMutableArray array]; - if(_urlSession) [sessions addObject:_urlSession]; - if(_wifiOnlyUrlSession) [sessions addObject:_wifiOnlyUrlSession]; - - for (NSURLSession *session in sessions) { - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { - for (NSURLSessionTask *uploadTask in uploadTasks) { - if ([uploadTask.taskDescription isEqualToString:cancelUploadId]){ - // == checks if references are equal, while isEqualToString checks the string value - [uploadTask cancel]; - } - } - dispatch_semaphore_signal(sema); - }]; - dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); - } - - resolve([NSNumber numberWithBool:YES]); -} - - - -/* - * Retrieve status of an upload task - */ -RCT_EXPORT_METHOD(getUploadStatus: (NSString *)uploadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSMutableArray *sessions = [NSMutableArray array]; - if(_urlSession) [sessions addObject:_urlSession]; - if(_wifiOnlyUrlSession) [sessions addObject:_wifiOnlyUrlSession]; - - __block Boolean resolved = false; - - - for (NSURLSession *session in sessions) { - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { - - for (NSURLSessionTask *uploadTask in uploadTasks) { - if (![uploadTask.taskDescription isEqualToString:uploadId]) continue; - NSDictionary *result = @{ - @"state": [Helper urlSessionTaskStateToString:[uploadTask state]], - @"bytesSent": [NSNumber numberWithUnsignedLongLong:[uploadTask countOfBytesSent]], - @"totalBytes": [NSNumber numberWithUnsignedLongLong:[uploadTask countOfBytesExpectedToSend]], - }; - resolve(result); - resolved = true; - break; - } - - dispatch_semaphore_signal(sema); - }]; - - dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); - if(resolved) return; - } - - resolve(NULL); -} - - -- (NSData *)createBodyWithBoundary:(NSString *)boundary - path:(NSString *)path - parameters:(NSDictionary *)parameters - fieldName:(NSString *)fieldName { - - NSMutableData *httpBody = [NSMutableData data]; - - // Escape non latin characters in filename - NSString *escapedPath = [path stringByAddingPercentEncodingWithAllowedCharacters: NSCharacterSet.URLQueryAllowedCharacterSet]; - - // resolve path - NSURL *fileUri = [NSURL URLWithString: escapedPath]; - - NSError* error = nil; - NSData *data = [NSData dataWithContentsOfURL:fileUri options:NSDataReadingMappedAlways error: &error]; - - if (data == nil) { - NSLog(@"Failed to read file %@", error); - } - - NSString *filename = [path lastPathComponent]; - NSString *mimetype = [self guessMIMETypeFromFileName:path]; - - [parameters enumerateKeysAndObjectsUsingBlock:^(NSString *parameterKey, NSString *parameterValue, BOOL *stop) { - [httpBody appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n", parameterKey] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"%@\r\n", parameterValue] dataUsingEncoding:NSUTF8StringEncoding]]; - }]; - - [httpBody appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\n", fieldName, filename] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Type: %@\r\n\r\n", mimetype] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:data]; - [httpBody appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]]; - - [httpBody appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - - return httpBody; -} - -- (NSURLSession *)urlSession { - if (_urlSession) return _urlSession; - - NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:BACKGROUND_SESSION_ID]; - - [sessionConfiguration setDiscretionary:NO]; - [sessionConfiguration setAllowsCellularAccess:YES]; - [sessionConfiguration setHTTPMaximumConnectionsPerHost:1]; - - if (@available(iOS 11.0, *)) { - [sessionConfiguration setWaitsForConnectivity:YES]; - } - - if (@available(iOS 13.0, *)) { - [sessionConfiguration setAllowsConstrainedNetworkAccess:YES]; - [sessionConfiguration setAllowsExpensiveNetworkAccess:YES]; - } - - _urlSession = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; - - return _urlSession; -} - -- (NSURLSession *)wifiOnlyUrlSession { - if (_wifiOnlyUrlSession) return _wifiOnlyUrlSession; - - NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:WIFI_ONLY_BACKGROUND_SESSION_ID]; - - [sessionConfiguration setDiscretionary:NO]; - [sessionConfiguration setAllowsCellularAccess:NO]; - [sessionConfiguration setHTTPMaximumConnectionsPerHost:1]; - - if (@available(iOS 11.0, *)) { - [sessionConfiguration setWaitsForConnectivity:YES]; - } - - if (@available(iOS 13.0, *)) { - [sessionConfiguration setAllowsConstrainedNetworkAccess:NO]; - [sessionConfiguration setAllowsExpensiveNetworkAccess:NO]; - } - - _wifiOnlyUrlSession = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; - - return _wifiOnlyUrlSession; -} - -#pragma NSURLSessionTaskDelegate - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task -didCompleteWithError:(NSError *)error { - NSMutableDictionary *data = [NSMutableDictionary dictionaryWithObjectsAndKeys:task.taskDescription, @"id", nil]; - NSURLSessionDataTask *uploadTask = (NSURLSessionDataTask *)task; - NSHTTPURLResponse *response = (NSHTTPURLResponse *)uploadTask.response; - if (response != nil) { - [data setObject:[NSNumber numberWithInteger:response.statusCode] forKey:@"responseCode"]; - } - //Add data that was collected earlier by the didReceiveData method - NSMutableData *responseData = _responsesData[@(task.taskIdentifier)]; - if (responseData) { - [_responsesData removeObjectForKey:@(task.taskIdentifier)]; - NSString *response = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; - [data setObject:response forKey:@"responseBody"]; - } else { - [data setObject:[NSNull null] forKey:@"responseBody"]; - } - - if (error == nil) { - [self _sendEventWithName:@"RNFileUploader-completed" body:data]; - } - else { - [data setObject:error.localizedDescription forKey:@"error"]; - if (error.code == NSURLErrorCancelled) { - [self _sendEventWithName:@"RNFileUploader-cancelled" body:data]; - } else { - [self _sendEventWithName:@"RNFileUploader-error" body:data]; - } - } -} - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task - didSendBodyData:(int64_t)bytesSent - totalBytesSent:(int64_t)totalBytesSent -totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend { - float progress = -1; - //see documentation. For unknown size it's -1 (NSURLSessionTransferSizeUnknown) - if (totalBytesExpectedToSend > 0) { - progress = 100.0 * (float)totalBytesSent / (float)totalBytesExpectedToSend; - } - - NSDictionary *data = @{ - @"id": task.taskDescription, - @"progress": [NSNumber numberWithFloat:progress] - }; - - [self _sendEventWithName:@"RNFileUploader-progress" body:data]; -} - -- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { - if (!data.length) { - return; - } - //Hold returned data so it can be picked up by the didCompleteWithError method later - NSMutableData *responseData = _responsesData[@(dataTask.taskIdentifier)]; - if (!responseData) { - responseData = [NSMutableData dataWithData:data]; - _responsesData[@(dataTask.taskIdentifier)] = responseData; - } else { - [responseData appendData:data]; - } -} - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task - needNewBodyStream:(void (^)(NSInputStream *bodyStream))completionHandler { - - NSInputStream *inputStream = task.originalRequest.HTTPBodyStream; - - if (completionHandler) { - completionHandler(inputStream); - } -} - -@end diff --git a/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj b/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj deleted file mode 100644 index 58b34f22..00000000 --- a/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj +++ /dev/null @@ -1,254 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - DC352C7A284AA8BB008DBB93 /* Helper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC352C79284AA8BB008DBB93 /* Helper.m */; }; - DCC748851E044F8700EA453E /* VydiaRNFileUploader.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 014A3B5A1C6CF33500B6D375 /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "include/$(PRODUCT_NAME)"; - dstSubfolderSpec = 16; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 014A3B5C1C6CF33500B6D375 /* libVydiaRNFileUploader.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; name = libVydiaRNFileUploader.a; path = "/Users/thomasvo/WebstormProjects/react-native-background-upload/ios/build/Debug-iphoneos/libVydiaRNFileUploader.a"; sourceTree = ""; }; - DC352C79284AA8BB008DBB93 /* Helper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Helper.m; sourceTree = ""; }; - DC352C7C284AAC6A008DBB93 /* Helper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Helper.h; sourceTree = ""; }; - DC352C7D284AADCF008DBB93 /* react-native-background-upload.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; name = "react-native-background-upload.podspec"; path = "../react-native-background-upload.podspec"; sourceTree = ""; }; - DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VydiaRNFileUploader.m; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 014A3B591C6CF33500B6D375 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 014A3B531C6CF33500B6D375 = { - isa = PBXGroup; - children = ( - DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */, - DC352C79284AA8BB008DBB93 /* Helper.m */, - DC352C7C284AAC6A008DBB93 /* Helper.h */, - DC352C7D284AADCF008DBB93 /* react-native-background-upload.podspec */, - ); - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 014A3B5B1C6CF33500B6D375 /* VydiaRNFileUploader */ = { - isa = PBXNativeTarget; - buildConfigurationList = 014A3B651C6CF33500B6D375 /* Build configuration list for PBXNativeTarget "VydiaRNFileUploader" */; - buildPhases = ( - 014A3B581C6CF33500B6D375 /* Sources */, - 014A3B591C6CF33500B6D375 /* Frameworks */, - 014A3B5A1C6CF33500B6D375 /* CopyFiles */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = VydiaRNFileUploader; - productName = VydiaRNFileUploader; - productReference = 014A3B5C1C6CF33500B6D375 /* libVydiaRNFileUploader.a */; - productType = "com.apple.product-type.library.static"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 014A3B541C6CF33500B6D375 /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0720; - ORGANIZATIONNAME = "Marc Shilling"; - TargetAttributes = { - 014A3B5B1C6CF33500B6D375 = { - CreatedOnToolsVersion = 7.2.1; - }; - }; - }; - buildConfigurationList = 014A3B571C6CF33500B6D375 /* Build configuration list for PBXProject "VydiaRNFileUploader" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - English, - en, - ); - mainGroup = 014A3B531C6CF33500B6D375; - productRefGroup = 014A3B531C6CF33500B6D375; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 014A3B5B1C6CF33500B6D375 /* VydiaRNFileUploader */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - 014A3B581C6CF33500B6D375 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DCC748851E044F8700EA453E /* VydiaRNFileUploader.m in Sources */, - DC352C7A284AA8BB008DBB93 /* Helper.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 014A3B631C6CF33500B6D375 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - }; - name = Debug; - }; - 014A3B641C6CF33500B6D375 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 014A3B661C6CF33500B6D375 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(SRCROOT)/../node_modules/react-native/React/**", - "$(SRCROOT)/../../react-native/React/**", - "$(SRCROOT)/../Example/node_modules/react-native/React/**", - "$(SRCROOT)/../../../ios/Pods/Headers/Public/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Debug; - }; - 014A3B671C6CF33500B6D375 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(SRCROOT)/../node_modules/react-native/React/**", - "$(SRCROOT)/../../react-native/React/**", - "$(SRCROOT)/../Example/node_modules/react-native/React/**", - "$(SRCROOT)/../../../ios/Pods/Headers/Public/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 014A3B571C6CF33500B6D375 /* Build configuration list for PBXProject "VydiaRNFileUploader" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 014A3B631C6CF33500B6D375 /* Debug */, - 014A3B641C6CF33500B6D375 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 014A3B651C6CF33500B6D375 /* Build configuration list for PBXNativeTarget "VydiaRNFileUploader" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 014A3B661C6CF33500B6D375 /* Debug */, - 014A3B671C6CF33500B6D375 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 014A3B541C6CF33500B6D375 /* Project object */; -} diff --git a/react-native-background-upload.podspec b/react-native-background-upload.podspec index a0897653..fd01a30b 100644 --- a/react-native-background-upload.podspec +++ b/react-native-background-upload.podspec @@ -1,18 +1,30 @@ require "json" - json = File.read(File.join(__dir__, "package.json")) - package = JSON.parse(json).deep_symbolize_keys +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) - Pod::Spec.new do |s| - s.name = package[:name] - s.version = package[:version] - s.license = { type: "MIT" } - s.homepage = "https://github.com/Vydia/react-native-background-upload" - s.authors = package[:author] - s.summary = package[:description] - s.source = { git: package[:repository][:url] } - s.source_files = "ios/*.{h,m}" - s.platform = :ios, "9.0" +Pod::Spec.new do |s| + s.name = package["name"] + s.version = package["version"] + s.license = { type: "MIT" } + s.homepage = "https://github.com/openspacelabs/react-native-background-upload" + s.authors = package["author"] + s.summary = package["description"] + s.source = { + git: "https://github.com/openspacelabs/react-native-background-upload.git", + tag: "v#{s.version}" + } - s.dependency "React" - end + s.source_files = "ios/**/*.{h,m,mm,swift}" + # RNFileUploader.h imports the codegen spec header, which is Obj-C++ only. Keep + # every header out of the public umbrella so a consumer's plain Obj-C + # `@import react_native_background_upload;` still compiles — that import is how + # the AppDelegate reaches RNBackgroundUpload's background-session handler. + s.private_header_files = "ios/**/*.h" + + s.platform = :ios, "15.1" + s.swift_version = "5.0" + s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } + + # Pulls in React-Core plus the New Architecture / TurboModule dependencies. + install_modules_dependencies(s) +end