按账号与相册隔离相机下载目录,并同步更新传输管道与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 15:55:54 +08:00
parent 660852ffad
commit 5f7ef24683
14 changed files with 382 additions and 163 deletions

View File

@ -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` 回调通知上层有新照片。
## 依赖

View File

@ -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 {

View File

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

View File

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