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>
This commit is contained in:
249
suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift
Normal file
249
suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift
Normal file
@ -0,0 +1,249 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user