按账号与相册隔离相机下载目录,并同步更新传输管道与测试。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -12,13 +12,13 @@
|
||||
| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 |
|
||||
| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 |
|
||||
| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 |
|
||||
| `CameraDownloadStorage` | 本地下载目录与文件名消毒 |
|
||||
| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 |
|
||||
|
||||
## 业务流程
|
||||
|
||||
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
|
||||
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。
|
||||
3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`,持久化路径为相对路径(如 `CameraDownloads/1730_DSC.JPG`)。
|
||||
3. 拍摄事件触发 PTP 下载,底层先写入临时目录,再由传输管道按账号与相册移入业务目录。
|
||||
4. 通过 `onNewAsset` 回调通知上层有新照片。
|
||||
|
||||
## 依赖
|
||||
|
||||
@ -612,7 +612,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
||||
lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)"
|
||||
|
||||
let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)"
|
||||
let destination = CameraDownloadStorage.uniqueLocalURL(for: result.filename)
|
||||
let destination = CameraDownloadStorage.uniqueTemporaryURL(for: result.filename)
|
||||
do {
|
||||
try result.data.write(to: destination, options: .atomic)
|
||||
} catch {
|
||||
|
||||
@ -95,7 +95,7 @@ final class SonyCameraService: CameraServiceProtocol {
|
||||
throw CameraServiceError.assetNotFound
|
||||
}
|
||||
|
||||
let directory = CameraDownloadStorage.downloadsDirectory
|
||||
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
|
||||
return try await deviceManager.download(file: file, to: directory)
|
||||
}
|
||||
|
||||
|
||||
@ -9,10 +9,30 @@ import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。
|
||||
/// 相机下载文件本地存储工具,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||||
enum CameraDownloadStorage {
|
||||
static let cameraDownloadsFolderName = "CameraDownloads"
|
||||
|
||||
/// 相机下载文件的业务隔离作用域。
|
||||
struct Scope: Equatable {
|
||||
let accountKey: String
|
||||
let albumID: Int
|
||||
|
||||
/// 初始化账号与相册维度的下载作用域。
|
||||
init(accountKey: String, albumID: Int) {
|
||||
self.accountKey = accountKey
|
||||
self.albumID = albumID
|
||||
}
|
||||
|
||||
var accountDirectoryName: String {
|
||||
CameraDownloadStorage.sanitizePathComponent(accountKey, fallback: "guest")
|
||||
}
|
||||
|
||||
var albumDirectoryName: String {
|
||||
"\(max(albumID, 0))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Documents 根目录。
|
||||
static var documentsDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
@ -25,24 +45,56 @@ enum CameraDownloadStorage {
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 相机照片列表缩略图目录。
|
||||
static var thumbnailsDirectory: URL {
|
||||
let directory = downloadsDirectory.appendingPathComponent("Thumbnails", isDirectory: true)
|
||||
/// 相机底层下载临时目录,最终业务记录不会持久化这里的路径。
|
||||
static var temporaryDownloadsDirectory: URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。
|
||||
static func relativePath(for url: URL) -> String {
|
||||
let standardizedURL = url.standardizedFileURL
|
||||
let thumbnailsPath = thumbnailsDirectory.standardizedFileURL.path
|
||||
if standardizedURL.path.hasPrefix(thumbnailsPath) {
|
||||
return "\(cameraDownloadsFolderName)/Thumbnails/\(url.lastPathComponent)"
|
||||
}
|
||||
return "\(cameraDownloadsFolderName)/\(url.lastPathComponent)"
|
||||
/// 当前账号与相册的下载根目录。
|
||||
static func scopeDirectory(for scope: Scope) -> URL {
|
||||
let directory = downloadsDirectory
|
||||
.appendingPathComponent(scope.accountDirectoryName, isDirectory: true)
|
||||
.appendingPathComponent(scope.albumDirectoryName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。
|
||||
/// 当前账号与相册的原图目录。
|
||||
static func originalsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("originals", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的缩略图目录。
|
||||
static func thumbnailsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("thumbnails", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/user_101/6603/originals/1730_DSC.JPG`。
|
||||
static func relativePath(for url: URL) -> String {
|
||||
let standardizedURL = url.standardizedFileURL
|
||||
let documentsPath = documentsDirectory.standardizedFileURL.path
|
||||
let filePath = standardizedURL.path
|
||||
if filePath == documentsPath {
|
||||
return ""
|
||||
}
|
||||
if filePath.hasPrefix(documentsPath + "/") {
|
||||
return String(filePath.dropFirst(documentsPath.count + 1))
|
||||
}
|
||||
let privateDocumentsPath = "/private" + documentsPath
|
||||
if filePath.hasPrefix(privateDocumentsPath + "/") {
|
||||
return String(filePath.dropFirst(privateDocumentsPath.count + 1))
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
/// 将持久化的相对路径、绝对路径或网络地址解析为可访问 URL。
|
||||
static func resolveLocalURL(from storedPath: String) -> URL? {
|
||||
guard !storedPath.isEmpty else { return nil }
|
||||
|
||||
@ -51,56 +103,46 @@ enum CameraDownloadStorage {
|
||||
}
|
||||
|
||||
if storedPath.hasPrefix("/") {
|
||||
let legacyURL = URL(fileURLWithPath: storedPath)
|
||||
if FileManager.default.fileExists(atPath: legacyURL.path) {
|
||||
return legacyURL
|
||||
}
|
||||
let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
return legacyURL
|
||||
return URL(fileURLWithPath: storedPath)
|
||||
}
|
||||
|
||||
return documentsDirectory.appendingPathComponent(storedPath)
|
||||
}
|
||||
|
||||
/// 将旧版绝对路径迁移为相对路径;若文件已在当前 CameraDownloads 中则更新引用。
|
||||
static func migrateStoredPath(_ storedPath: String) -> String? {
|
||||
guard storedPath.hasPrefix("/") else { return storedPath }
|
||||
|
||||
let legacyURL = URL(fileURLWithPath: storedPath)
|
||||
if FileManager.default.fileExists(atPath: legacyURL.path) {
|
||||
return relativePath(for: legacyURL)
|
||||
}
|
||||
|
||||
let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return relativePath(for: candidate)
|
||||
}
|
||||
|
||||
return relativePath(for: legacyURL)
|
||||
}
|
||||
|
||||
/// 判断文件是否位于 CameraDownloads 目录内。
|
||||
static func isInDownloadsDirectory(_ url: URL) -> Bool {
|
||||
let downloadsPath = downloadsDirectory.standardizedFileURL.path
|
||||
/// 判断文件是否位于当前账号与相册的原图目录内。
|
||||
static func isInOriginalsDirectory(_ url: URL, scope: Scope) -> Bool {
|
||||
let originalsPath = originalsDirectory(for: scope).standardizedFileURL.path
|
||||
let filePath = url.standardizedFileURL.path
|
||||
return filePath.hasPrefix(downloadsPath) || filePath.hasPrefix("/private" + downloadsPath)
|
||||
return filePath.hasPrefix(originalsPath + "/") || filePath.hasPrefix("/private" + originalsPath + "/")
|
||||
}
|
||||
|
||||
/// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。
|
||||
static func uniqueLocalURL(for filename: String) -> URL {
|
||||
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||||
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let safeName = sanitizeFilename(filename)
|
||||
return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)")
|
||||
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||||
}
|
||||
|
||||
/// 生成带 asset ID 的唯一缩略图文件 URL。
|
||||
static func uniqueThumbnailURL(for assetID: String) -> URL {
|
||||
/// 生成相机底层临时下载 URL。
|
||||
static func uniqueTemporaryURL(for filename: String) -> URL {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let safeName = sanitizeFilename(filename)
|
||||
return temporaryDownloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)")
|
||||
}
|
||||
|
||||
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
||||
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
||||
let safeName = sanitizeFilename(assetID)
|
||||
.replacingOccurrences(of: ".", with: "_")
|
||||
return thumbnailsDirectory.appendingPathComponent("\(safeName)_thumb.jpg")
|
||||
return thumbnailsDirectory(for: scope).appendingPathComponent("\(safeName)_thumb.jpg")
|
||||
}
|
||||
|
||||
/// 删除当前账号与相册的本地文件目录。
|
||||
static func removeScopeDirectory(for scope: Scope) {
|
||||
let directory = scopeDirectory(for: scope)
|
||||
if FileManager.default.fileExists(atPath: directory.path) {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||
@ -124,6 +166,21 @@ enum CameraDownloadStorage {
|
||||
return name
|
||||
}
|
||||
|
||||
/// 将账号等动态字符串转为安全目录片段。
|
||||
static func sanitizePathComponent(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-")
|
||||
}
|
||||
|
||||
let component = String(String.UnicodeScalarView(ascii))
|
||||
return component.isEmpty ? fallback : component
|
||||
}
|
||||
|
||||
/// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。
|
||||
static func previewURLString(from storedPath: String) -> String {
|
||||
guard let url = resolveLocalURL(from: storedPath),
|
||||
@ -147,7 +204,7 @@ enum CameraThumbnailGenerator {
|
||||
private static let maxPixelSize: CGFloat = 160
|
||||
|
||||
/// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。
|
||||
static func generateThumbnail(for imageURL: URL, assetID: String) async -> String? {
|
||||
static func generateThumbnail(for imageURL: URL, assetID: String, scope: CameraDownloadStorage.Scope) async -> String? {
|
||||
await Task.detached(priority: .utility) {
|
||||
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||||
let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
|
||||
@ -164,7 +221,7 @@ enum CameraThumbnailGenerator {
|
||||
return nil
|
||||
}
|
||||
|
||||
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID)
|
||||
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID, scope: scope)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
|
||||
@ -11,16 +11,19 @@
|
||||
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
|
||||
| `CameraTransferTask` | 单条传输任务及状态 |
|
||||
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
|
||||
| `CameraDownloadStorage.Scope` | 账号 + 相册维度的本地文件隔离作用域 |
|
||||
|
||||
## 业务流程
|
||||
|
||||
1. 相机服务回调 `onNewAsset`。
|
||||
2. Pipeline 下载文件到本地并更新任务状态。
|
||||
2. Pipeline 下载文件到当前 scope 的 `originals` 目录并更新任务状态。
|
||||
3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。
|
||||
4. 上传成功后任务状态变为 `uploaded`。
|
||||
|
||||
`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。
|
||||
|
||||
本地文件路径按 `Documents/CameraDownloads/<accountKey>/<albumID>/originals/` 存放原图,缩略图由业务层生成到同 scope 的 `thumbnails/`。持久化只记录相对 Documents 的 scoped 路径,不保留未上线旧目录兼容。
|
||||
|
||||
## 与业务层关系
|
||||
|
||||
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。
|
||||
|
||||
@ -16,6 +16,7 @@ final class CameraTransferPipeline {
|
||||
private let cameraService: any CameraServiceProtocol
|
||||
private var uploadSink: (any CameraAssetUploadSink)?
|
||||
private var uploadEnabled = true
|
||||
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
||||
private var activeUploadAssetIDs: Set<String> = []
|
||||
private var autoUploadEligibleAssetIDs: Set<String> = []
|
||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||
@ -36,10 +37,17 @@ final class CameraTransferPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置上传 Sink 与是否自动上传。
|
||||
func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) {
|
||||
/// 配置上传 Sink、自动上传开关与可选下载作用域。
|
||||
func configure(
|
||||
uploadSink: (any CameraAssetUploadSink)?,
|
||||
uploadEnabled: Bool,
|
||||
downloadScope: CameraDownloadStorage.Scope? = nil
|
||||
) {
|
||||
self.uploadSink = uploadSink
|
||||
self.uploadEnabled = uploadEnabled
|
||||
if let downloadScope {
|
||||
self.downloadScope = downloadScope
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动相机连接。
|
||||
@ -243,10 +251,10 @@ final class CameraTransferPipeline {
|
||||
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
||||
let destination: URL
|
||||
|
||||
if CameraDownloadStorage.isInDownloadsDirectory(localURL) {
|
||||
if CameraDownloadStorage.isInOriginalsDirectory(localURL, scope: downloadScope) {
|
||||
destination = localURL
|
||||
} else {
|
||||
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename)
|
||||
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename, scope: downloadScope)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 本地文件 URL,支持相对路径与旧版绝对路径。
|
||||
/// 本地文件 URL,支持相对 Documents 的 scoped 路径与绝对路径。
|
||||
var localURL: URL? {
|
||||
guard let localPath, !localPath.isEmpty else { return nil }
|
||||
return CameraDownloadStorage.resolveLocalURL(from: localPath)
|
||||
|
||||
Reference in New Issue
Block a user