Files
suixinkan_ios_new/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift
汉秋 d2fe5d71e4 Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:41:05 +08:00

250 lines
8.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// CameraTransferPipeline.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// Sink
@MainActor
final class CameraTransferPipeline {
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
private(set) var tasks: [CameraTransferTask] = []
private let cameraService: any CameraServiceProtocol
private var uploadSink: (any CameraAssetUploadSink)?
private var uploadEnabled = true
private var isProcessing = false
private var inFlightAssetIDs: Set<String> = []
private let maxRetries = 3
///
init(cameraService: any CameraServiceProtocol) {
self.cameraService = cameraService
cameraService.onNewAsset = { [weak self] asset in
Task { @MainActor in
await self?.handleNewAsset(asset, skipIfExists: false)
}
}
}
/// Sink
func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) {
self.uploadSink = uploadSink
self.uploadEnabled = uploadEnabled
}
///
func connect() async {
await cameraService.connect()
}
///
func disconnect() async {
await cameraService.disconnect()
}
///
var connectionState: CameraConnectionState {
cameraService.connectionState
}
///
var onConnectionStateChange: ((CameraConnectionState) -> Void)? {
get { nil }
set {
cameraService.onConnectionStateChange = newValue
}
}
///
func retryFailedUploads() async {
guard uploadEnabled, uploadSink != nil else { return }
for index in tasks.indices where tasks[index].status == .failed {
tasks[index].status = .downloaded
tasks[index].errorMessage = nil
}
notify()
await processUploadQueue()
}
/// asset
func uploadAssets(withIDs assetIDs: [String]) async {
guard uploadEnabled, uploadSink != nil else { return }
for assetID in assetIDs {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
if tasks[index].status == .uploaded { continue }
if tasks[index].localURL == nil {
await downloadAsset(at: index)
}
if tasks[index].status == .downloaded || tasks[index].status == .failed {
tasks[index].status = .downloaded
await uploadTask(at: index)
}
}
}
///
func syncExistingPhotos() async {
guard cameraService.connectionState.isConnected else { return }
do {
let assets = try await cameraService.listAssets()
for asset in assets {
await handleNewAsset(asset, skipIfExists: true)
}
} catch {
CameraTetheringLogger.log("syncExistingPhotos 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
}
}
///
func clearAllTransfers() {
for task in tasks {
guard let localURL = task.localURL,
FileManager.default.fileExists(atPath: localURL.path)
else { continue }
try? FileManager.default.removeItem(at: localURL)
}
tasks.removeAll()
notify()
}
/// assetID
func task(forAssetID assetID: String) -> CameraTransferTask? {
tasks.first { $0.assetID == assetID }
}
private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async {
if skipIfExists,
let existing = tasks.first(where: { $0.assetID == asset.id }),
existing.status == .uploaded || existing.status == .downloaded {
return
}
if inFlightAssetIDs.contains(asset.id) { return }
if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return }
inFlightAssetIDs.insert(asset.id)
defer { inFlightAssetIDs.remove(asset.id) }
let taskIndex: Int
if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) {
taskIndex = existingIndex
tasks[taskIndex].status = .downloading
tasks[taskIndex].errorMessage = nil
} else {
let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading)
tasks.insert(task, at: 0)
taskIndex = 0
}
notify()
await downloadAsset(at: taskIndex, asset: asset)
if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded {
await processUploadQueue()
}
}
private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async {
guard tasks.indices.contains(index) else { return }
let assetID = tasks[index].assetID
do {
let resolvedAsset: CameraAsset
if let asset {
resolvedAsset = asset
} else if let cached = try? await cameraService.listAssets().first(where: { $0.id == assetID }) {
resolvedAsset = cached
} else {
throw CameraServiceError.assetNotFound
}
let localURL = try await cameraService.downloadAsset(resolvedAsset)
let destination: URL
if localURL.path.hasPrefix(CameraDownloadStorage.downloadsDirectory.path) {
destination = localURL
} else {
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: localURL, to: destination)
}
tasks[index].localPath = destination.path
tasks[index].status = .downloaded
tasks[index].progress = 0
notify()
} catch {
tasks[index].status = .failed
tasks[index].errorMessage = error.localizedDescription
notify()
}
}
private func processUploadQueue() async {
guard uploadEnabled, uploadSink != nil else { return }
guard !isProcessing else { return }
isProcessing = true
defer { isProcessing = false }
let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded }
for index in pendingIndices {
await uploadTask(at: index)
}
}
private func uploadTask(at index: Int) async {
guard tasks.indices.contains(index),
let sink = uploadSink,
let localURL = tasks[index].localURL
else { return }
tasks[index].status = .uploading
tasks[index].errorMessage = nil
notify()
let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2
var attempt = 0
while attempt < maxRetries {
do {
let remoteURL = try await sink.upload(
localURL: localURL,
fileName: tasks[index].filename,
fileType: fileType,
progress: { [weak self] progress in
Task { @MainActor in
guard let self, self.tasks.indices.contains(index) else { return }
self.tasks[index].progress = progress
self.notify()
}
}
)
tasks[index].status = .uploaded
tasks[index].remoteURL = remoteURL
tasks[index].progress = 100
notify()
return
} catch {
attempt += 1
if attempt >= maxRetries {
tasks[index].status = .failed
tasks[index].errorMessage = error.localizedDescription
notify()
} else {
let delay = UInt64(pow(2.0, Double(attempt)))
try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
}
}
}
}
private func notify() {
onTasksUpdated?(tasks)
}
}