215 lines
7.4 KiB
Swift
215 lines
7.4 KiB
Swift
import SQLiteData
|
|
|
|
private let stateLock = NSLock()
|
|
private var transferStates: [Int64: NetworkTransferState] = [:]
|
|
private var responseData: [Int64: Data] = [:]
|
|
private let jsonDecoder = JSONDecoder()
|
|
|
|
private class NetworkTransferState {
|
|
var lastUpdateTime: Date
|
|
var totalBytesTransferred: Int64
|
|
var currentSpeed: Double?
|
|
|
|
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
|
self.lastUpdateTime = lastUpdateTime
|
|
self.totalBytesTransferred = totalBytesTransferred
|
|
self.currentSpeed = currentSpeed
|
|
}
|
|
}
|
|
|
|
final class UploadApiDelegate<
|
|
TaskRepo: TaskProtocol,
|
|
StatusListener: TaskStatusListener,
|
|
ProgressListener: TaskProgressListener
|
|
>: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
|
private let taskRepository: TaskRepo
|
|
private let statusListener: StatusListener
|
|
private let progressListener: ProgressListener
|
|
|
|
init(taskRepository: TaskRepo, statusListener: StatusListener, progressListener: ProgressListener) {
|
|
self.taskRepository = taskRepository
|
|
self.statusListener = statusListener
|
|
self.progressListener = progressListener
|
|
}
|
|
|
|
static func reset() {
|
|
stateLock.withLock {
|
|
transferStates.removeAll()
|
|
responseData.removeAll()
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
guard let taskIdStr = dataTask.taskDescription,
|
|
let taskId = Int64(taskIdStr)
|
|
else { return }
|
|
|
|
stateLock.withLock {
|
|
if var response = responseData[taskId] {
|
|
response.append(data)
|
|
} else {
|
|
responseData[taskId] = data
|
|
}
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
Task {
|
|
defer {
|
|
NotificationCenter.default.post(name: .uploadTaskDidComplete, object: nil)
|
|
}
|
|
|
|
guard let taskDescriptionId = task.taskDescription,
|
|
let taskId = Int64(taskDescriptionId)
|
|
else {
|
|
return dPrint("Unexpected: task without session ID completed")
|
|
}
|
|
|
|
defer {
|
|
stateLock.withLock { let _ = transferStates.removeValue(forKey: taskId) }
|
|
}
|
|
|
|
if let body = stateLock.withLock({ responseData.removeValue(forKey: taskId) }),
|
|
let response = task.response as? HTTPURLResponse
|
|
{
|
|
switch response.statusCode {
|
|
case 200, 201:
|
|
do {
|
|
let response = try jsonDecoder.decode(UploadSuccessResponse.self, from: body)
|
|
return await handleSuccess(taskId: taskId, response: response)
|
|
} catch {
|
|
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
|
}
|
|
case 401: return await handleFailure(taskId: taskId, code: .unauthorized)
|
|
case 400..<500:
|
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
|
return await handleFailure(taskId: taskId, code: .badRequest)
|
|
case 500..<600:
|
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
|
return await handleFailure(taskId: taskId, code: .internalServerError)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
guard let urlError = error as? URLError else {
|
|
return await handleFailure(taskId: taskId)
|
|
}
|
|
|
|
if #available(iOS 17, *), let resumeData = urlError.uploadTaskResumeData {
|
|
return await handleFailure(taskDescriptionId: taskDescriptionId, session: session, resumeData: resumeData)
|
|
}
|
|
|
|
let code: UploadErrorCode =
|
|
switch urlError.backgroundTaskCancelledReason {
|
|
case .backgroundUpdatesDisabled: .backgroundUpdatesDisabled
|
|
case .insufficientSystemResources: .outOfResources
|
|
case .userForceQuitApplication: .forceQuit
|
|
default:
|
|
switch urlError.code {
|
|
case .networkConnectionLost, .notConnectedToInternet: .networkError
|
|
case .timedOut: .uploadTimeout
|
|
case .resourceUnavailable, .fileDoesNotExist: .fileNotFound
|
|
default: .unknown
|
|
}
|
|
}
|
|
await handleFailure(taskId: taskId, code: code)
|
|
}
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
task: URLSessionTask,
|
|
didSendBodyData bytesSent: Int64,
|
|
totalBytesSent: Int64,
|
|
totalBytesExpectedToSend: Int64
|
|
) {
|
|
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
|
let currentTime = Date()
|
|
let state = stateLock.withLock {
|
|
if let existing = transferStates[taskId] {
|
|
return existing
|
|
}
|
|
let new = NetworkTransferState(
|
|
lastUpdateTime: currentTime,
|
|
totalBytesTransferred: totalBytesSent,
|
|
currentSpeed: nil
|
|
)
|
|
transferStates[taskId] = new
|
|
return new
|
|
}
|
|
|
|
let timeDelta = currentTime.timeIntervalSince(state.lastUpdateTime)
|
|
guard timeDelta > 0 else { return }
|
|
|
|
let bytesDelta = totalBytesSent - state.totalBytesTransferred
|
|
let instantSpeed = Double(bytesDelta) / timeDelta
|
|
let currentSpeed =
|
|
if let previousSpeed = state.currentSpeed {
|
|
TaskConfig.transferSpeedAlpha * instantSpeed + (1 - TaskConfig.transferSpeedAlpha) * previousSpeed
|
|
} else {
|
|
instantSpeed
|
|
}
|
|
state.currentSpeed = currentSpeed
|
|
state.lastUpdateTime = currentTime
|
|
state.totalBytesTransferred = totalBytesSent
|
|
self.progressListener.onTaskProgress(
|
|
UploadApiTaskProgress(
|
|
id: sessionTaskId,
|
|
progress: Double(totalBytesSent) / Double(totalBytesExpectedToSend),
|
|
speed: currentSpeed
|
|
)
|
|
)
|
|
}
|
|
|
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
dPrint("All background events delivered for session: \(session.configuration.identifier ?? "unknown")")
|
|
DispatchQueue.main.async {
|
|
if let identifier = session.configuration.identifier,
|
|
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
|
let completionHandler = appDelegate.completionHandler(forSession: identifier)
|
|
{
|
|
completionHandler()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
|
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
|
do {
|
|
try await taskRepository.markUploadSuccess(taskId: taskId, livePhotoVideoId: response.id)
|
|
statusListener.onTaskStatus(
|
|
UploadApiTaskStatus(
|
|
id: String(taskId),
|
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
|
status: .uploadComplete
|
|
)
|
|
)
|
|
} catch {
|
|
dPrint(
|
|
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
|
dPrint("Upload failed for task \(taskId) with code \(code)")
|
|
try? await taskRepository.retryOrFail(taskId: taskId, code: code, status: .uploadFailed)
|
|
statusListener.onTaskStatus(
|
|
UploadApiTaskStatus(
|
|
id: String(taskId),
|
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
|
status: .uploadFailed
|
|
)
|
|
)
|
|
}
|
|
|
|
@available(iOS 17, *)
|
|
private func handleFailure(taskDescriptionId: String, session: URLSession, resumeData: Data) async {
|
|
dPrint("Resuming upload for task \(taskDescriptionId)")
|
|
let resumeTask = session.uploadTask(withResumeData: resumeData)
|
|
resumeTask.taskDescription = taskDescriptionId
|
|
resumeTask.resume()
|
|
}
|
|
}
|