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:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,20 @@
//
// CameraAssetUploadSink.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// OSS +
@MainActor
protocol CameraAssetUploadSink: AnyObject {
/// URL
func upload(
localURL: URL,
fileName: String,
fileType: Int,
progress: @escaping @Sendable (Int) -> Void
) async throws -> String
}

View File

@ -0,0 +1,25 @@
# CameraTransfer
## 职责
编排相机照片从下载到上传的通用流程,通过 `CameraAssetUploadSink` 与业务层解耦。
## 核心组件
| 组件 | 说明 |
| --- | --- |
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
| `CameraTransferTask` | 单条传输任务及状态 |
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
## 业务流程
1. 相机服务回调 `onNewAsset`
2. Pipeline 下载文件到本地并更新任务状态。
3. 若开启自动上传,调用 `CameraAssetUploadSink.upload`
4. 上传成功后任务状态变为 `uploaded`
## 与业务层关系
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 SinkOSS 上传 + `upload-material` 登记。
- Pipeline 不依赖任何 HTTP 或 OSS 具体实现。

View 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)
}
}

View File

@ -0,0 +1,56 @@
//
// CameraTransferTask.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
///
enum CameraTransferStatus: String, Codable, Equatable {
case pending
case downloading
case downloaded
case uploading
case uploaded
case failed
}
///
struct CameraTransferTask: Identifiable, Equatable, Codable {
let id: UUID
let assetID: String
let filename: String
var localPath: String?
var remoteURL: String?
var status: CameraTransferStatus
var progress: Int
var errorMessage: String?
init(
id: UUID = UUID(),
assetID: String,
filename: String,
localPath: String? = nil,
remoteURL: String? = nil,
status: CameraTransferStatus = .pending,
progress: Int = 0,
errorMessage: String? = nil
) {
self.id = id
self.assetID = assetID
self.filename = filename
self.localPath = localPath
self.remoteURL = remoteURL
self.status = status
self.progress = progress
self.errorMessage = errorMessage
}
/// URL
var localURL: URL? {
guard let localPath, !localPath.isEmpty else { return nil }
return URL(fileURLWithPath: localPath)
}
}