按账号与相册隔离相机下载目录,并同步更新传输管道与测试。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,15 +64,11 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -328,10 +324,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
@ -12,13 +12,13 @@
|
|||||||
| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 |
|
| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 |
|
||||||
| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 |
|
| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 |
|
||||||
| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 |
|
| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 |
|
||||||
| `CameraDownloadStorage` | 本地下载目录与文件名消毒 |
|
| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 |
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
|
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
|
||||||
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。
|
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。
|
||||||
3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`,持久化路径为相对路径(如 `CameraDownloads/1730_DSC.JPG`)。
|
3. 拍摄事件触发 PTP 下载,底层先写入临时目录,再由传输管道按账号与相册移入业务目录。
|
||||||
4. 通过 `onNewAsset` 回调通知上层有新照片。
|
4. 通过 `onNewAsset` 回调通知上层有新照片。
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|||||||
@ -612,7 +612,7 @@ final class ImageCaptureDeviceManager: NSObject {
|
|||||||
lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)"
|
lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)"
|
||||||
|
|
||||||
let assetID = "ptp_\(handle)_\(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 {
|
do {
|
||||||
try result.data.write(to: destination, options: .atomic)
|
try result.data.write(to: destination, options: .atomic)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -95,7 +95,7 @@ final class SonyCameraService: CameraServiceProtocol {
|
|||||||
throw CameraServiceError.assetNotFound
|
throw CameraServiceError.assetNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
let directory = CameraDownloadStorage.downloadsDirectory
|
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
|
||||||
return try await deviceManager.download(file: file, to: directory)
|
return try await deviceManager.download(file: file, to: directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,30 @@ import Foundation
|
|||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。
|
/// 相机下载文件本地存储工具,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||||||
enum CameraDownloadStorage {
|
enum CameraDownloadStorage {
|
||||||
static let cameraDownloadsFolderName = "CameraDownloads"
|
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 根目录。
|
/// Documents 根目录。
|
||||||
static var documentsDirectory: URL {
|
static var documentsDirectory: URL {
|
||||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
@ -25,24 +45,56 @@ enum CameraDownloadStorage {
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机照片列表缩略图目录。
|
/// 相机底层下载临时目录,最终业务记录不会持久化这里的路径。
|
||||||
static var thumbnailsDirectory: URL {
|
static var temporaryDownloadsDirectory: URL {
|
||||||
let directory = downloadsDirectory.appendingPathComponent("Thumbnails", isDirectory: true)
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。
|
/// 当前账号与相册的下载根目录。
|
||||||
static func relativePath(for url: URL) -> String {
|
static func scopeDirectory(for scope: Scope) -> URL {
|
||||||
let standardizedURL = url.standardizedFileURL
|
let directory = downloadsDirectory
|
||||||
let thumbnailsPath = thumbnailsDirectory.standardizedFileURL.path
|
.appendingPathComponent(scope.accountDirectoryName, isDirectory: true)
|
||||||
if standardizedURL.path.hasPrefix(thumbnailsPath) {
|
.appendingPathComponent(scope.albumDirectoryName, isDirectory: true)
|
||||||
return "\(cameraDownloadsFolderName)/Thumbnails/\(url.lastPathComponent)"
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
}
|
return directory
|
||||||
return "\(cameraDownloadsFolderName)/\(url.lastPathComponent)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 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? {
|
static func resolveLocalURL(from storedPath: String) -> URL? {
|
||||||
guard !storedPath.isEmpty else { return nil }
|
guard !storedPath.isEmpty else { return nil }
|
||||||
|
|
||||||
@ -51,56 +103,46 @@ enum CameraDownloadStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if storedPath.hasPrefix("/") {
|
if storedPath.hasPrefix("/") {
|
||||||
let legacyURL = URL(fileURLWithPath: storedPath)
|
return 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 documentsDirectory.appendingPathComponent(storedPath)
|
return documentsDirectory.appendingPathComponent(storedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将旧版绝对路径迁移为相对路径;若文件已在当前 CameraDownloads 中则更新引用。
|
/// 判断文件是否位于当前账号与相册的原图目录内。
|
||||||
static func migrateStoredPath(_ storedPath: String) -> String? {
|
static func isInOriginalsDirectory(_ url: URL, scope: Scope) -> Bool {
|
||||||
guard storedPath.hasPrefix("/") else { return storedPath }
|
let originalsPath = originalsDirectory(for: scope).standardizedFileURL.path
|
||||||
|
|
||||||
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
|
|
||||||
let filePath = url.standardizedFileURL.path
|
let filePath = url.standardizedFileURL.path
|
||||||
return filePath.hasPrefix(downloadsPath) || filePath.hasPrefix("/private" + downloadsPath)
|
return filePath.hasPrefix(originalsPath + "/") || filePath.hasPrefix("/private" + originalsPath + "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。
|
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||||||
static func uniqueLocalURL(for filename: String) -> URL {
|
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let safeName = sanitizeFilename(filename)
|
let safeName = sanitizeFilename(filename)
|
||||||
return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)")
|
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成带 asset ID 的唯一缩略图文件 URL。
|
/// 生成相机底层临时下载 URL。
|
||||||
static func uniqueThumbnailURL(for assetID: String) -> 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)
|
let safeName = sanitizeFilename(assetID)
|
||||||
.replacingOccurrences(of: ".", with: "_")
|
.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 名称。
|
/// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||||
@ -124,6 +166,21 @@ enum CameraDownloadStorage {
|
|||||||
return name
|
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 字符串。
|
/// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。
|
||||||
static func previewURLString(from storedPath: String) -> String {
|
static func previewURLString(from storedPath: String) -> String {
|
||||||
guard let url = resolveLocalURL(from: storedPath),
|
guard let url = resolveLocalURL(from: storedPath),
|
||||||
@ -147,7 +204,7 @@ enum CameraThumbnailGenerator {
|
|||||||
private static let maxPixelSize: CGFloat = 160
|
private static let maxPixelSize: CGFloat = 160
|
||||||
|
|
||||||
/// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。
|
/// 为本地照片生成列表缩略图,成功时返回相对 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) {
|
await Task.detached(priority: .utility) {
|
||||||
guard FileManager.default.fileExists(atPath: imageURL.path),
|
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||||||
let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
|
let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
|
||||||
@ -164,7 +221,7 @@ enum CameraThumbnailGenerator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID)
|
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID, scope: scope)
|
||||||
if FileManager.default.fileExists(atPath: destination.path) {
|
if FileManager.default.fileExists(atPath: destination.path) {
|
||||||
try? FileManager.default.removeItem(at: destination)
|
try? FileManager.default.removeItem(at: destination)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,16 +11,19 @@
|
|||||||
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
|
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
|
||||||
| `CameraTransferTask` | 单条传输任务及状态 |
|
| `CameraTransferTask` | 单条传输任务及状态 |
|
||||||
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
|
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
|
||||||
|
| `CameraDownloadStorage.Scope` | 账号 + 相册维度的本地文件隔离作用域 |
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
1. 相机服务回调 `onNewAsset`。
|
1. 相机服务回调 `onNewAsset`。
|
||||||
2. Pipeline 下载文件到本地并更新任务状态。
|
2. Pipeline 下载文件到当前 scope 的 `originals` 目录并更新任务状态。
|
||||||
3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。
|
3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。
|
||||||
4. 上传成功后任务状态变为 `uploaded`。
|
4. 上传成功后任务状态变为 `uploaded`。
|
||||||
|
|
||||||
`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。
|
`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。
|
||||||
|
|
||||||
|
本地文件路径按 `Documents/CameraDownloads/<accountKey>/<albumID>/originals/` 存放原图,缩略图由业务层生成到同 scope 的 `thumbnails/`。持久化只记录相对 Documents 的 scoped 路径,不保留未上线旧目录兼容。
|
||||||
|
|
||||||
## 与业务层关系
|
## 与业务层关系
|
||||||
|
|
||||||
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。
|
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。
|
||||||
|
|||||||
@ -16,6 +16,7 @@ final class CameraTransferPipeline {
|
|||||||
private let cameraService: any CameraServiceProtocol
|
private let cameraService: any CameraServiceProtocol
|
||||||
private var uploadSink: (any CameraAssetUploadSink)?
|
private var uploadSink: (any CameraAssetUploadSink)?
|
||||||
private var uploadEnabled = true
|
private var uploadEnabled = true
|
||||||
|
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
||||||
private var activeUploadAssetIDs: Set<String> = []
|
private var activeUploadAssetIDs: Set<String> = []
|
||||||
private var autoUploadEligibleAssetIDs: Set<String> = []
|
private var autoUploadEligibleAssetIDs: Set<String> = []
|
||||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||||
@ -36,10 +37,17 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置上传 Sink 与是否自动上传。
|
/// 配置上传 Sink、自动上传开关与可选下载作用域。
|
||||||
func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) {
|
func configure(
|
||||||
|
uploadSink: (any CameraAssetUploadSink)?,
|
||||||
|
uploadEnabled: Bool,
|
||||||
|
downloadScope: CameraDownloadStorage.Scope? = nil
|
||||||
|
) {
|
||||||
self.uploadSink = uploadSink
|
self.uploadSink = uploadSink
|
||||||
self.uploadEnabled = uploadEnabled
|
self.uploadEnabled = uploadEnabled
|
||||||
|
if let downloadScope {
|
||||||
|
self.downloadScope = downloadScope
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动相机连接。
|
/// 启动相机连接。
|
||||||
@ -243,10 +251,10 @@ final class CameraTransferPipeline {
|
|||||||
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
||||||
let destination: URL
|
let destination: URL
|
||||||
|
|
||||||
if CameraDownloadStorage.isInDownloadsDirectory(localURL) {
|
if CameraDownloadStorage.isInOriginalsDirectory(localURL, scope: downloadScope) {
|
||||||
destination = localURL
|
destination = localURL
|
||||||
} else {
|
} else {
|
||||||
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename)
|
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename, scope: downloadScope)
|
||||||
if FileManager.default.fileExists(atPath: destination.path) {
|
if FileManager.default.fileExists(atPath: destination.path) {
|
||||||
try FileManager.default.removeItem(at: destination)
|
try FileManager.default.removeItem(at: destination)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// 本地文件 URL,支持相对路径与旧版绝对路径。
|
/// 本地文件 URL,支持相对 Documents 的 scoped 路径与绝对路径。
|
||||||
var localURL: URL? {
|
var localURL: URL? {
|
||||||
guard let localPath, !localPath.isEmpty else { return nil }
|
guard let localPath, !localPath.isEmpty else { return nil }
|
||||||
return CameraDownloadStorage.resolveLocalURL(from: localPath)
|
return CameraDownloadStorage.resolveLocalURL(from: localPath)
|
||||||
|
|||||||
@ -92,9 +92,7 @@ final class WiredTransferPhotoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
|
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
|
||||||
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
|
return normalizedRecords
|
||||||
|
|
||||||
return migratedRecords
|
|
||||||
.filter(\.isDisplayable)
|
.filter(\.isDisplayable)
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
.also { items in
|
.also { items in
|
||||||
@ -102,27 +100,6 @@ final class WiredTransferPhotoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将旧版绝对路径迁移为相对路径并回写持久化。
|
|
||||||
private func normalizeStoredPaths(
|
|
||||||
_ records: [WiredTransferPhotoRecord],
|
|
||||||
albumID: Int
|
|
||||||
) -> [WiredTransferPhotoRecord] {
|
|
||||||
var migrated = records
|
|
||||||
var needsPersist = false
|
|
||||||
|
|
||||||
for index in migrated.indices {
|
|
||||||
guard let normalized = migrated[index].withNormalizedStoredPaths(),
|
|
||||||
normalized != migrated[index] else { continue }
|
|
||||||
migrated[index] = normalized
|
|
||||||
needsPersist = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsPersist {
|
|
||||||
save(albumID: albumID, records: migrated)
|
|
||||||
}
|
|
||||||
return migrated
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存相册传图记录。
|
/// 保存相册传图记录。
|
||||||
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
||||||
guard albumID > 0 else { return }
|
guard albumID > 0 else { return }
|
||||||
@ -173,12 +150,18 @@ final class WiredTransferPhotoStore {
|
|||||||
|
|
||||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||||
else { return [] }
|
else {
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(albumID: albumID))
|
||||||
|
defaults.removeObject(forKey: storageKey(albumID))
|
||||||
|
defaults.removeObject(forKey: deletedKey(albumID))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
let userRecords = records.filter { $0.userId == userID }
|
let userRecords = records.filter { $0.userId == userID }
|
||||||
userRecords.forEach { deleteRecordFiles($0) }
|
userRecords.forEach { deleteRecordFiles($0) }
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(albumID: albumID))
|
||||||
|
|
||||||
var bindings = loadBindings().filter { $0.albumID != albumID }
|
let bindings = loadBindings().filter { $0.albumID != albumID }
|
||||||
saveBindings(bindings)
|
saveBindings(bindings)
|
||||||
|
|
||||||
defaults.removeObject(forKey: storageKey(albumID))
|
defaults.removeObject(forKey: storageKey(albumID))
|
||||||
@ -216,6 +199,10 @@ final class WiredTransferPhotoStore {
|
|||||||
"\(accountPrefixProvider())\(Self.transferModeKeySuffix)"
|
"\(accountPrefixProvider())\(Self.transferModeKeySuffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func downloadScope(albumID: Int) -> CameraDownloadStorage.Scope {
|
||||||
|
CameraDownloadStorage.Scope(accountKey: accountPrefixProvider(), albumID: albumID)
|
||||||
|
}
|
||||||
|
|
||||||
private struct PhotoAlbumBinding: Codable {
|
private struct PhotoAlbumBinding: Codable {
|
||||||
let photoID: String
|
let photoID: String
|
||||||
let albumID: Int
|
let albumID: Int
|
||||||
@ -249,33 +236,6 @@ private extension WiredTransferPhotoRecord {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将 localPath/thumbnailPath 中的旧版绝对路径迁移为相对路径。
|
|
||||||
func withNormalizedStoredPaths() -> WiredTransferPhotoRecord? {
|
|
||||||
let migratedLocalPath = localPath.isEmpty ? localPath : (CameraDownloadStorage.migrateStoredPath(localPath) ?? localPath)
|
|
||||||
let migratedThumbnailPath = thumbnailPath.isEmpty
|
|
||||||
? thumbnailPath
|
|
||||||
: (CameraDownloadStorage.migrateStoredPath(thumbnailPath) ?? thumbnailPath)
|
|
||||||
|
|
||||||
guard migratedLocalPath != localPath || migratedThumbnailPath != thumbnailPath else { return nil }
|
|
||||||
|
|
||||||
return WiredTransferPhotoRecord(
|
|
||||||
id: id,
|
|
||||||
sourceId: sourceId,
|
|
||||||
fileName: fileName,
|
|
||||||
localPath: migratedLocalPath,
|
|
||||||
thumbnailPath: migratedThumbnailPath,
|
|
||||||
capturedAt: capturedAt,
|
|
||||||
fileSizeBytes: fileSizeBytes,
|
|
||||||
status: status,
|
|
||||||
progress: progress,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
albumId: albumId,
|
|
||||||
userId: userId,
|
|
||||||
remoteURL: remoteURL,
|
|
||||||
updatedAt: updatedAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
||||||
guard status == WiredTransferUploadStatus.transferring.rawValue
|
guard status == WiredTransferUploadStatus.transferring.rawValue
|
||||||
|| status == WiredTransferUploadStatus.uploading.rawValue
|
|| status == WiredTransferUploadStatus.uploading.rawValue
|
||||||
|
|||||||
@ -72,14 +72,14 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
||||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||||
4. 素材登记:`POST .../travel-album/upload-material`
|
4. 素材登记:`POST .../travel-album/upload-material`
|
||||||
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
|
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||||
|
|
||||||
## 有线传图性能策略
|
## 有线传图性能策略
|
||||||
|
|
||||||
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
- 本地下载原图只用于上传与大图预览,按 `accountCachePrefix + albumId` 写入 `CameraDownloads/<accountKey>/<albumID>/originals/`;列表缩略图在后台下采样生成到同 scope 的 `thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||||
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||||
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
||||||
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
- 本地记录和物理文件都以账号上下文和相册维度隔离,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存;删除相册会清理当前账号当前相册的 scoped 文件目录,不影响同手机号下其他账号或其他相册。
|
||||||
|
|
||||||
## 解耦关系
|
## 解耦关系
|
||||||
|
|
||||||
|
|||||||
@ -443,6 +443,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private let pipeline: CameraTransferPipeline
|
private let pipeline: CameraTransferPipeline
|
||||||
private var photoStore: WiredTransferPhotoStore?
|
private var photoStore: WiredTransferPhotoStore?
|
||||||
private var userIDProvider: (() -> String)?
|
private var userIDProvider: (() -> String)?
|
||||||
|
private var downloadScope: CameraDownloadStorage.Scope?
|
||||||
private var sessionNewPhotoIDs: Set<String> = []
|
private var sessionNewPhotoIDs: Set<String> = []
|
||||||
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||||||
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
|
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
|
||||||
@ -477,9 +478,16 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
scenicID: Int,
|
scenicID: Int,
|
||||||
accountContext: AccountContext
|
accountContext: AccountContext
|
||||||
) async {
|
) async {
|
||||||
|
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
||||||
|
let currentDownloadScope = CameraDownloadStorage.Scope(
|
||||||
|
accountKey: accountCachePrefix,
|
||||||
|
albumID: context.albumId
|
||||||
|
)
|
||||||
|
downloadScope = currentDownloadScope
|
||||||
|
|
||||||
if photoStore == nil {
|
if photoStore == nil {
|
||||||
photoStore = WiredTransferPhotoStore(
|
photoStore = WiredTransferPhotoStore(
|
||||||
accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" },
|
accountPrefixProvider: { accountCachePrefix },
|
||||||
userIDProvider: { accountContext.profile?.userId ?? "" }
|
userIDProvider: { accountContext.profile?.userId ?? "" }
|
||||||
)
|
)
|
||||||
userIDProvider = { accountContext.profile?.userId ?? "" }
|
userIDProvider = { accountContext.profile?.userId ?? "" }
|
||||||
@ -494,7 +502,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
scenicID: scenicID
|
scenicID: scenicID
|
||||||
)
|
)
|
||||||
let autoUpload = transferModeOption == Self.modeLiveCapture
|
let autoUpload = transferModeOption == Self.modeLiveCapture
|
||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||||
refreshDeviceStorageInfo()
|
refreshDeviceStorageInfo()
|
||||||
loadPersistedPhotos()
|
loadPersistedPhotos()
|
||||||
await pipeline.connect()
|
await pipeline.connect()
|
||||||
@ -555,7 +563,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
albumID: context.albumId,
|
albumID: context.albumId,
|
||||||
scenicID: scenicID
|
scenicID: scenicID
|
||||||
)
|
)
|
||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: downloadScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除错误提示。
|
/// 清除错误提示。
|
||||||
@ -784,7 +792,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
albumID: context.albumId,
|
albumID: context.albumId,
|
||||||
scenicID: scenicID
|
scenicID: scenicID
|
||||||
)
|
)
|
||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: true)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: true, downloadScope: downloadScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
|
private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
|
||||||
@ -979,9 +987,14 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
|
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
|
||||||
if thumbnailPath.isEmpty,
|
if thumbnailPath.isEmpty,
|
||||||
|
let downloadScope,
|
||||||
let localURL,
|
let localURL,
|
||||||
status != .transferring,
|
status != .transferring,
|
||||||
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(for: localURL, assetID: task.assetID) {
|
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(
|
||||||
|
for: localURL,
|
||||||
|
assetID: task.assetID,
|
||||||
|
scope: downloadScope
|
||||||
|
) {
|
||||||
thumbnailPath = generatedPath
|
thumbnailPath = generatedPath
|
||||||
}
|
}
|
||||||
let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
|
let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
|
||||||
|
|||||||
@ -8,36 +8,73 @@ import XCTest
|
|||||||
|
|
||||||
/// CameraDownloadStorage 相对路径持久化测试。
|
/// CameraDownloadStorage 相对路径持久化测试。
|
||||||
final class CameraDownloadStorageTests: XCTestCase {
|
final class CameraDownloadStorageTests: XCTestCase {
|
||||||
/// 测试相对路径前缀。
|
/// 测试 scoped 原图相对路径前缀。
|
||||||
func testRelativePathUsesCameraDownloadsPrefix() {
|
func testRelativePathUsesScopedOriginalsPrefix() {
|
||||||
let fileURL = CameraDownloadStorage.downloadsDirectory.appendingPathComponent("1730_test.JPG")
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
XCTAssertEqual(CameraDownloadStorage.relativePath(for: fileURL), "CameraDownloads/1730_test.JPG")
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
|
let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG")
|
||||||
|
XCTAssertEqual(
|
||||||
|
CameraDownloadStorage.relativePath(for: fileURL),
|
||||||
|
"CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试相对路径解析。
|
/// 测试相对路径解析。
|
||||||
func testResolveLocalURLFromRelativePath() {
|
func testResolveLocalURLFromRelativePath() {
|
||||||
let stored = "CameraDownloads/1730_test.JPG"
|
let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
||||||
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
|
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
|
||||||
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
|
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
|
||||||
XCTAssertEqual(resolved, expected)
|
XCTAssertEqual(resolved, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试旧版绝对路径迁移。
|
/// 测试账号或相册不同不会生成相同目录。
|
||||||
func testMigrateStoredPathFromLegacyAbsolutePath() throws {
|
func testScopedDirectoriesDoNotCollideAcrossAccountOrAlbum() {
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy.jpg")
|
let accountScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
try Data("jpeg".utf8).write(to: fileURL)
|
let otherAccountScope = CameraDownloadStorage.Scope(accountKey: "user_102", albumID: 6603)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
let otherAlbumScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
|
||||||
|
defer {
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: accountScope)
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: otherAccountScope)
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: otherAlbumScope)
|
||||||
|
}
|
||||||
|
|
||||||
let migrated = CameraDownloadStorage.migrateStoredPath(fileURL.path)
|
XCTAssertNotEqual(
|
||||||
XCTAssertEqual(migrated, "CameraDownloads/\(fileURL.lastPathComponent)")
|
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
|
||||||
XCTAssertEqual(CameraDownloadStorage.resolveLocalURL(from: migrated ?? "")?.path, fileURL.path)
|
CameraDownloadStorage.originalsDirectory(for: otherAccountScope).path
|
||||||
|
)
|
||||||
|
XCTAssertNotEqual(
|
||||||
|
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
|
||||||
|
CameraDownloadStorage.originalsDirectory(for: otherAlbumScope).path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 scoped 目录清理只影响目标相册。
|
||||||
|
func testRemoveScopeDirectoryOnlyDeletesTargetScope() throws {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
let otherScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
|
||||||
|
defer {
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "target.jpg", scope: scope)
|
||||||
|
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "other.jpg", scope: otherScope)
|
||||||
|
try Data("target".utf8).write(to: fileURL)
|
||||||
|
try Data("other".utf8).write(to: otherFileURL)
|
||||||
|
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
||||||
|
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试预览 URL 字符串生成。
|
/// 测试预览 URL 字符串生成。
|
||||||
func testPreviewURLStringFromRelativePath() throws {
|
func testPreviewURLStringFromRelativePath() throws {
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg")
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg", scope: scope)
|
||||||
try Data("jpeg".utf8).write(to: fileURL)
|
try Data("jpeg".utf8).write(to: fileURL)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
let stored = CameraDownloadStorage.relativePath(for: fileURL)
|
let stored = CameraDownloadStorage.relativePath(for: fileURL)
|
||||||
let preview = CameraDownloadStorage.previewURLString(from: stored)
|
let preview = CameraDownloadStorage.previewURLString(from: stored)
|
||||||
|
|||||||
@ -16,7 +16,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockUploadSink()
|
let sink = MockUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
let scope = makeScope("new_asset")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
|
|
||||||
var latestTasks: [CameraTransferTask] = []
|
var latestTasks: [CameraTransferTask] = []
|
||||||
pipeline.onTasksUpdated = { latestTasks = $0 }
|
pipeline.onTasksUpdated = { latestTasks = $0 }
|
||||||
@ -30,7 +32,10 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 1)
|
XCTAssertEqual(sink.uploadCount, 1)
|
||||||
let savedTask = latestTasks.first { $0.assetID == asset.id }
|
let savedTask = latestTasks.first { $0.assetID == asset.id }
|
||||||
XCTAssertEqual(savedTask?.localPath?.hasPrefix("CameraDownloads/"), true)
|
XCTAssertEqual(
|
||||||
|
savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"),
|
||||||
|
true
|
||||||
|
)
|
||||||
XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
|
XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
|
||||||
if let localURL = savedTask?.localURL {
|
if let localURL = savedTask?.localURL {
|
||||||
try? FileManager.default.removeItem(at: localURL)
|
try? FileManager.default.removeItem(at: localURL)
|
||||||
@ -43,9 +48,11 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockUploadSink()
|
let sink = MockUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: false)
|
let scope = makeScope("retry")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG")
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope)
|
||||||
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
@ -65,7 +72,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockSlowUploadSink()
|
let sink = MockSlowUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
let scope = makeScope("manual")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
camera.listedAssets = (1 ... 6).map { index in
|
camera.listedAssets = (1 ... 6).map { index in
|
||||||
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
|
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
|
||||||
}
|
}
|
||||||
@ -83,7 +92,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockUploadSink()
|
let sink = MockUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: false)
|
let scope = makeScope("mode_switch")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
|
||||||
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
|
camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
|
||||||
try await Task.sleep(nanoseconds: 400_000_000)
|
try await Task.sleep(nanoseconds: 400_000_000)
|
||||||
@ -91,7 +102,7 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
XCTAssertEqual(sink.uploadedFileNames, [])
|
XCTAssertEqual(sink.uploadedFileNames, [])
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
|
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
|
||||||
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024))
|
camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024))
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
|
||||||
@ -105,7 +116,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockUploadSink()
|
let sink = MockUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
let scope = makeScope("sync_existing")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
camera.listedAssets = [
|
camera.listedAssets = [
|
||||||
CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024)
|
CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024)
|
||||||
]
|
]
|
||||||
@ -122,7 +135,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockBurstCameraService()
|
let camera = MockBurstCameraService()
|
||||||
let sink = MockSlowUploadSink()
|
let sink = MockSlowUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
let scope = makeScope("burst")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
|
|
||||||
for index in 1 ... 10 {
|
for index in 1 ... 10 {
|
||||||
let filename = "DSC_\(index).JPG"
|
let filename = "DSC_\(index).JPG"
|
||||||
@ -149,7 +164,9 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockChattyUploadSink()
|
let sink = MockChattyUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
let scope = makeScope("chatty")
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||||
|
|
||||||
var updateCount = 0
|
var updateCount = 0
|
||||||
pipeline.onTasksUpdated = { _ in updateCount += 1 }
|
pipeline.onTasksUpdated = { _ in updateCount += 1 }
|
||||||
@ -160,6 +177,13 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded)
|
XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded)
|
||||||
XCTAssertLessThan(updateCount, 18)
|
XCTAssertLessThan(updateCount, 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope {
|
||||||
|
let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in
|
||||||
|
(partial + Int(scalar.value)) % 10_000
|
||||||
|
}
|
||||||
|
return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -173,9 +173,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG")
|
let scope = downloadScope(account: account, albumID: context.albumId)
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope)
|
||||||
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
camera.downloadURL = fileURL
|
camera.downloadURL = fileURL
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
|
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
|
||||||
@ -253,9 +254,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
let account = AccountContext()
|
let account = AccountContext()
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG")
|
let scope = downloadScope(account: account, albumID: context.albumId)
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG", scope: scope)
|
||||||
try Data(repeating: 0xCD, count: 32).write(to: fileURL)
|
try Data(repeating: 0xCD, count: 32).write(to: fileURL)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
let photoID = "persist_failed_001"
|
let photoID = "persist_failed_001"
|
||||||
let record = WiredTransferPhotoRecord(
|
let record = WiredTransferPhotoRecord(
|
||||||
@ -304,12 +306,13 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
|
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
|
||||||
|
|
||||||
let fileURLs = try (1 ... 5).map { index in
|
let fileURLs = try (1 ... 5).map { index in
|
||||||
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG")
|
let scope = downloadScope(account: account, albumID: albumID)
|
||||||
|
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG", scope: scope)
|
||||||
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
|
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
fileURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(account: account, albumID: albumID))
|
||||||
}
|
}
|
||||||
|
|
||||||
let records = fileURLs.enumerated().map { offset, url in
|
let records = fileURLs.enumerated().map { offset, url in
|
||||||
@ -410,18 +413,19 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
accountContext: account
|
accountContext: account
|
||||||
)
|
)
|
||||||
|
|
||||||
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG")
|
let scope = downloadScope(account: account, albumID: albumID)
|
||||||
|
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope)
|
||||||
try makeJPEGData().write(to: imageURL)
|
try makeJPEGData().write(to: imageURL)
|
||||||
defer { try? FileManager.default.removeItem(at: imageURL) }
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
camera.downloadURL = imageURL
|
camera.downloadURL = imageURL
|
||||||
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
|
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
|
||||||
try await Task.sleep(nanoseconds: 900_000_000)
|
try await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
|
||||||
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
|
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
|
||||||
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/Thumbnails/"))
|
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/"))
|
||||||
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
|
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
|
||||||
XCTAssertFalse(photo.previewURL.contains("/Thumbnails/"))
|
XCTAssertFalse(photo.previewURL.contains("/thumbnails/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。
|
/// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。
|
||||||
@ -429,9 +433,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
let albumID = 6602
|
let albumID = 6602
|
||||||
let account = AccountContext()
|
let account = AccountContext()
|
||||||
account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试"))
|
account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试"))
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG")
|
let scope = downloadScope(account: account, albumID: albumID)
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG", scope: scope)
|
||||||
try makeJPEGData().write(to: fileURL)
|
try makeJPEGData().write(to: fileURL)
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
let record = WiredTransferPhotoRecord(
|
let record = WiredTransferPhotoRecord(
|
||||||
id: "legacy_photo_001",
|
id: "legacy_photo_001",
|
||||||
@ -471,6 +476,114 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(photo.previewURL.isEmpty)
|
XCTAssertFalse(photo.previewURL.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试清空相册时只删除当前账号当前相册的物理目录。
|
||||||
|
func testClearAlbumRemovesOnlyCurrentAlbumScopedDirectory() throws {
|
||||||
|
let userID = "clear-scope-user"
|
||||||
|
let account = AccountContext()
|
||||||
|
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"), accountType: "store")
|
||||||
|
let albumID = 8801
|
||||||
|
let otherAlbumID = 8802
|
||||||
|
let scope = downloadScope(account: account, albumID: albumID)
|
||||||
|
let otherScope = downloadScope(account: account, albumID: otherAlbumID)
|
||||||
|
let scopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: scope).path
|
||||||
|
let otherScopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: otherScope).path
|
||||||
|
defer {
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope)
|
||||||
|
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope)
|
||||||
|
try Data(repeating: 0x01, count: 8).write(to: fileURL)
|
||||||
|
try Data(repeating: 0x02, count: 8).write(to: otherFileURL)
|
||||||
|
|
||||||
|
let store = WiredTransferPhotoStore(
|
||||||
|
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||||
|
userIDProvider: { userID }
|
||||||
|
)
|
||||||
|
store.save(albumID: albumID, records: [
|
||||||
|
makePhotoRecord(
|
||||||
|
id: "clear_target",
|
||||||
|
fileName: "clear_target.JPG",
|
||||||
|
localPath: CameraDownloadStorage.relativePath(for: fileURL),
|
||||||
|
albumID: albumID,
|
||||||
|
userID: userID
|
||||||
|
)
|
||||||
|
])
|
||||||
|
store.save(albumID: otherAlbumID, records: [
|
||||||
|
makePhotoRecord(
|
||||||
|
id: "clear_other",
|
||||||
|
fileName: "clear_other.JPG",
|
||||||
|
localPath: CameraDownloadStorage.relativePath(for: otherFileURL),
|
||||||
|
albumID: otherAlbumID,
|
||||||
|
userID: userID
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
let deletedIDs = store.clearAlbum(albumID: albumID)
|
||||||
|
|
||||||
|
XCTAssertEqual(deletedIDs, Set(["clear_target"]))
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试同一用户 ID 在不同账号类型下的有线传图记录与物理目录隔离。
|
||||||
|
func testSameUserIDDifferentAccountTypesAreIsolated() throws {
|
||||||
|
let userID = "multi-account-user"
|
||||||
|
let albumID = 8803
|
||||||
|
let storeAccount = AccountContext()
|
||||||
|
storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store")
|
||||||
|
let photographerAccount = AccountContext()
|
||||||
|
photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer")
|
||||||
|
let storeScope = downloadScope(account: storeAccount, albumID: albumID)
|
||||||
|
let photographerScope = downloadScope(account: photographerAccount, albumID: albumID)
|
||||||
|
defer {
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: storeScope)
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: photographerScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope)
|
||||||
|
let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope)
|
||||||
|
try Data(repeating: 0x03, count: 8).write(to: storeURL)
|
||||||
|
try Data(repeating: 0x04, count: 8).write(to: photographerURL)
|
||||||
|
|
||||||
|
let store = WiredTransferPhotoStore(
|
||||||
|
accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" },
|
||||||
|
userIDProvider: { userID }
|
||||||
|
)
|
||||||
|
let photographerStore = WiredTransferPhotoStore(
|
||||||
|
accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" },
|
||||||
|
userIDProvider: { userID }
|
||||||
|
)
|
||||||
|
store.save(albumID: albumID, records: [
|
||||||
|
makePhotoRecord(
|
||||||
|
id: "store_photo",
|
||||||
|
fileName: "store_photo.JPG",
|
||||||
|
localPath: CameraDownloadStorage.relativePath(for: storeURL),
|
||||||
|
albumID: albumID,
|
||||||
|
userID: userID
|
||||||
|
)
|
||||||
|
])
|
||||||
|
photographerStore.save(albumID: albumID, records: [
|
||||||
|
makePhotoRecord(
|
||||||
|
id: "photographer_photo",
|
||||||
|
fileName: "photographer_photo.JPG",
|
||||||
|
localPath: CameraDownloadStorage.relativePath(for: photographerURL),
|
||||||
|
albumID: albumID,
|
||||||
|
userID: userID
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(store.load(albumID: albumID).map(\.id), ["store_photo"])
|
||||||
|
XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"])
|
||||||
|
XCTAssertNotEqual(
|
||||||
|
CameraDownloadStorage.scopeDirectory(for: storeScope).path,
|
||||||
|
CameraDownloadStorage.scopeDirectory(for: photographerScope).path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||||
@ -536,6 +649,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
|
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func downloadScope(account: AccountContext, albumID: Int) -> CameraDownloadStorage.Scope {
|
||||||
|
CameraDownloadStorage.Scope(accountKey: account.accountCachePrefix ?? "guest_", albumID: albumID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
Reference in New Issue
Block a user