未连接状态改为「未连接相机」;设备型号映射为可读名称而非 iPhone12,1 等内部代号;边拍边传/拍后传输按账号持久化并在下次进入时恢复。 Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
11 KiB
Swift
312 lines
11 KiB
Swift
//
|
||
// WiredTransferPhotoStore.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/29.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 有线传图本地记录仓库,按相册与用户维度持久化到 UserDefaults。
|
||
final class WiredTransferPhotoStore {
|
||
private let defaults: UserDefaults
|
||
private let accountPrefixProvider: () -> String
|
||
private let userIDProvider: () -> String
|
||
|
||
private static let keyPrefix = "wired_transfer_photos_"
|
||
private static let deletedSuffix = "_deleted"
|
||
private static let bindingsSuffix = "_photo_bindings"
|
||
private static let transferModeKeySuffix = "wired_transfer_mode_option"
|
||
static let modeLiveCapture = "边拍边传"
|
||
static let modePostShootTransfer = "拍后传输"
|
||
|
||
/// 读取缓存的传输模式选项。
|
||
func loadTransferModeOption() -> String? {
|
||
let value = defaults.string(forKey: transferModeKey())?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
switch value {
|
||
case Self.modeLiveCapture, Self.modePostShootTransfer:
|
||
return value
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// 缓存传输模式选项。
|
||
func saveTransferModeOption(_ option: String) {
|
||
switch option {
|
||
case Self.modeLiveCapture, Self.modePostShootTransfer:
|
||
defaults.set(option, forKey: transferModeKey())
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// 初始化仓库,注入账号前缀与用户 ID 提供者。
|
||
init(
|
||
defaults: UserDefaults = .standard,
|
||
accountPrefixProvider: @escaping () -> String,
|
||
userIDProvider: @escaping () -> String
|
||
) {
|
||
self.defaults = defaults
|
||
self.accountPrefixProvider = accountPrefixProvider
|
||
self.userIDProvider = userIDProvider
|
||
}
|
||
|
||
/// 查询照片绑定的相册 ID。
|
||
func albumID(forPhoto photoID: String) -> Int? {
|
||
guard !photoID.isEmpty else { return nil }
|
||
return loadBindings().first { $0.photoID == photoID }?.albumID
|
||
}
|
||
|
||
/// 绑定照片到相册。
|
||
func bindPhotoToAlbum(photoID: String, albumID: Int) {
|
||
guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return }
|
||
var bindings = loadBindings().filter { $0.photoID != photoID }
|
||
bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID))
|
||
saveBindings(bindings)
|
||
}
|
||
|
||
/// 加载已删除照片 ID。
|
||
func loadDeletedIDs(albumID: Int) -> Set<String> {
|
||
guard albumID > 0, !userIDProvider().isEmpty else { return [] }
|
||
let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? []
|
||
return Set(ids)
|
||
}
|
||
|
||
/// 加载相册下的传图记录。
|
||
func load(albumID: Int) -> [WiredTransferPhotoRecord] {
|
||
guard albumID > 0 else { return [] }
|
||
let userID = userIDProvider()
|
||
guard !userID.isEmpty else { return [] }
|
||
|
||
let deletedIDs = loadDeletedIDs(albumID: albumID)
|
||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||
else { return [] }
|
||
|
||
let filteredRecords = records.filter { record in
|
||
!deletedIDs.contains(record.id)
|
||
&& record.userId == userID
|
||
&& record.albumId == albumID
|
||
}
|
||
|
||
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
|
||
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
|
||
|
||
return migratedRecords
|
||
.filter(\.isDisplayable)
|
||
.sorted { $0.capturedAt > $1.capturedAt }
|
||
.also { items in
|
||
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
||
}
|
||
}
|
||
|
||
/// 将旧版绝对路径迁移为相对路径并回写持久化。
|
||
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]) {
|
||
guard albumID > 0 else { return }
|
||
let userID = userIDProvider()
|
||
guard !userID.isEmpty else { return }
|
||
|
||
let normalized = records
|
||
.filter { $0.userId == userID && $0.albumId == albumID }
|
||
.sorted { $0.capturedAt > $1.capturedAt }
|
||
normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
||
|
||
if let data = try? JSONEncoder().encode(normalized) {
|
||
defaults.set(data, forKey: storageKey(albumID))
|
||
}
|
||
}
|
||
|
||
/// 标记并移除单张照片。
|
||
func remove(albumID: Int, photoID: String) {
|
||
guard albumID > 0, !photoID.isEmpty else { return }
|
||
markDeleted(albumID: albumID, photoID: photoID)
|
||
let deletedIDs = loadDeletedIDs(albumID: albumID)
|
||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||
else { return }
|
||
records
|
||
.filter { $0.id == photoID && $0.userId == userIDProvider() && $0.albumId == albumID }
|
||
.forEach { deleteRecordFiles($0) }
|
||
records = records.filter { record in
|
||
!deletedIDs.contains(record.id)
|
||
&& record.userId == userIDProvider()
|
||
&& record.albumId == albumID
|
||
}
|
||
save(albumID: albumID, records: records)
|
||
}
|
||
|
||
/// 标记照片已删除。
|
||
func markDeleted(albumID: Int, photoID: String) {
|
||
guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return }
|
||
let updated = loadDeletedIDs(albumID: albumID).union([photoID])
|
||
defaults.set(Array(updated), forKey: deletedKey(albumID))
|
||
}
|
||
|
||
/// 清空相册关联的本地缓存。
|
||
func clearAlbum(albumID: Int) -> Set<String> {
|
||
guard albumID > 0 else { return [] }
|
||
let userID = userIDProvider()
|
||
guard !userID.isEmpty else { return [] }
|
||
|
||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||
else { return [] }
|
||
|
||
let userRecords = records.filter { $0.userId == userID }
|
||
userRecords.forEach { deleteRecordFiles($0) }
|
||
|
||
var bindings = loadBindings().filter { $0.albumID != albumID }
|
||
saveBindings(bindings)
|
||
|
||
defaults.removeObject(forKey: storageKey(albumID))
|
||
defaults.removeObject(forKey: deletedKey(albumID))
|
||
|
||
return Set(userRecords.map(\.id))
|
||
}
|
||
|
||
private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) {
|
||
deleteFileIfExists(record.localPath)
|
||
deleteFileIfExists(record.thumbnailPath)
|
||
}
|
||
|
||
private func deleteFileIfExists(_ path: String) {
|
||
guard !path.isEmpty else { return }
|
||
guard let url = CameraDownloadStorage.resolveLocalURL(from: path) else { return }
|
||
if FileManager.default.fileExists(atPath: url.path) {
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
}
|
||
|
||
private func storageKey(_ albumID: Int) -> String {
|
||
"\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)"
|
||
}
|
||
|
||
private func deletedKey(_ albumID: Int) -> String {
|
||
"\(storageKey(albumID))\(Self.deletedSuffix)"
|
||
}
|
||
|
||
private func bindingsKey() -> String {
|
||
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
|
||
}
|
||
|
||
private func transferModeKey() -> String {
|
||
"\(accountPrefixProvider())\(Self.transferModeKeySuffix)"
|
||
}
|
||
|
||
private struct PhotoAlbumBinding: Codable {
|
||
let photoID: String
|
||
let albumID: Int
|
||
}
|
||
|
||
private func loadBindings() -> [PhotoAlbumBinding] {
|
||
guard let data = defaults.data(forKey: bindingsKey()),
|
||
let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data)
|
||
else { return [] }
|
||
return bindings
|
||
}
|
||
|
||
private func saveBindings(_ bindings: [PhotoAlbumBinding]) {
|
||
if let data = try? JSONEncoder().encode(bindings) {
|
||
defaults.set(data, forKey: bindingsKey())
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension WiredTransferPhotoRecord {
|
||
var isDisplayable: Bool {
|
||
if !remoteURL.isEmpty { return true }
|
||
if let url = CameraDownloadStorage.resolveLocalURL(from: localPath),
|
||
FileManager.default.fileExists(atPath: url.path) {
|
||
return true
|
||
}
|
||
if let url = CameraDownloadStorage.resolveLocalURL(from: thumbnailPath),
|
||
FileManager.default.fileExists(atPath: url.path) {
|
||
return true
|
||
}
|
||
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 {
|
||
guard status == WiredTransferUploadStatus.transferring.rawValue
|
||
|| status == WiredTransferUploadStatus.uploading.rawValue
|
||
else { return self }
|
||
|
||
var copy = self
|
||
copy = WiredTransferPhotoRecord(
|
||
id: id,
|
||
sourceId: sourceId,
|
||
fileName: fileName,
|
||
localPath: localPath,
|
||
thumbnailPath: thumbnailPath,
|
||
capturedAt: capturedAt,
|
||
fileSizeBytes: fileSizeBytes,
|
||
status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue,
|
||
progress: remoteURL.isEmpty ? 0 : 100,
|
||
errorMessage: errorMessage,
|
||
albumId: albumId,
|
||
userId: userId,
|
||
remoteURL: remoteURL,
|
||
updatedAt: updatedAt
|
||
)
|
||
return copy
|
||
}
|
||
}
|
||
|
||
private extension Array {
|
||
@discardableResult
|
||
func also(_ body: (Self) -> Void) -> Self {
|
||
body(self)
|
||
return self
|
||
}
|
||
}
|