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>
250 lines
8.6 KiB
Swift
250 lines
8.6 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|