439 lines
17 KiB
Swift
439 lines
17 KiB
Swift
//
|
||
// TravelAlbumOTGPhotoStore.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。
|
||
enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable {
|
||
case pending = "PENDING"
|
||
case transferring = "TRANSFERRING"
|
||
case uploading = "UPLOADING"
|
||
case uploaded = "UPLOADED"
|
||
case failed = "FAILED"
|
||
}
|
||
|
||
/// OTG 照片格式上传偏好。
|
||
enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable {
|
||
case jpg = "JPG"
|
||
case raw = "RAW"
|
||
case jpegRaw = "JPEG+RAW"
|
||
}
|
||
|
||
/// OTG 本地照片记录,按账号、景区、门店与服务端相册 ID 隔离保存。
|
||
struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||
let id: String
|
||
let sourceId: String
|
||
let fileName: String
|
||
var localPath: String
|
||
var thumbnailPath: String
|
||
let capturedAt: String
|
||
let fileSizeBytes: Int64
|
||
var status: TravelAlbumOTGUploadStatus
|
||
var progress: Int
|
||
var errorMessage: String?
|
||
let albumId: Int
|
||
let userId: String
|
||
var remoteUrl: String
|
||
var updatedAt: Int64
|
||
|
||
/// 创建 OTG 本地照片记录。
|
||
init(
|
||
id: String,
|
||
sourceId: String? = nil,
|
||
fileName: String,
|
||
localPath: String,
|
||
thumbnailPath: String = "",
|
||
capturedAt: String,
|
||
fileSizeBytes: Int64,
|
||
status: TravelAlbumOTGUploadStatus,
|
||
progress: Int = 0,
|
||
errorMessage: String? = nil,
|
||
albumId: Int,
|
||
userId: String,
|
||
remoteUrl: String = "",
|
||
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
||
) {
|
||
self.id = id
|
||
self.sourceId = sourceId ?? id
|
||
self.fileName = fileName
|
||
self.localPath = localPath
|
||
self.thumbnailPath = thumbnailPath
|
||
self.capturedAt = capturedAt
|
||
self.fileSizeBytes = fileSizeBytes
|
||
self.status = status
|
||
self.progress = progress
|
||
self.errorMessage = errorMessage
|
||
self.albumId = albumId
|
||
self.userId = userId
|
||
self.remoteUrl = remoteUrl
|
||
self.updatedAt = updatedAt
|
||
}
|
||
|
||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||
guard status == .transferring || status == .uploading else { return self }
|
||
var copy = self
|
||
copy.status = .pending
|
||
copy.progress = 0
|
||
copy.errorMessage = nil
|
||
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||
return copy
|
||
}
|
||
}
|
||
|
||
/// 相册内照片的持久化读写接口,供 OTG 边拍边传服务写入。
|
||
protocol PhotoRepositoryProtocol {
|
||
/// 读取指定服务端相册 ID 的本地照片记录。
|
||
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord]
|
||
|
||
/// 添加一张已下载到本地的照片。
|
||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws
|
||
|
||
/// 删除指定照片及本地文件。
|
||
func deletePhoto(photoID: String, albumID: Int) throws
|
||
}
|
||
|
||
/// OTG 本地照片仓库,使用 JSON 索引和隔离目录保存照片。
|
||
final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
||
private let storage: TravelAlbumOTGPhotoStore
|
||
|
||
/// 初始化 OTG 本地照片仓库。
|
||
init(storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore()) {
|
||
self.storage = storage
|
||
}
|
||
|
||
/// 读取指定服务端相册 ID 的本地照片记录。
|
||
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord] {
|
||
storage.load(albumId: albumID)
|
||
}
|
||
|
||
/// 添加一张已下载到本地的照片。
|
||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws {
|
||
let fileURL = URL(fileURLWithPath: localPath)
|
||
let attributes = try? FileManager.default.attributesOfItem(atPath: localPath)
|
||
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||
let userId = storage.context.userId
|
||
guard !userId.isEmpty else { return }
|
||
|
||
let record = TravelAlbumOTGPhotoRecord(
|
||
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
||
fileName: fileURL.lastPathComponent,
|
||
localPath: storage.relativePath(for: fileURL, albumId: albumID),
|
||
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt),
|
||
fileSizeBytes: size,
|
||
status: .pending,
|
||
albumId: albumID,
|
||
userId: userId
|
||
)
|
||
storage.upsert(record, albumId: albumID)
|
||
}
|
||
|
||
/// 删除指定照片及本地文件。
|
||
func deletePhoto(photoID: String, albumID: Int) throws {
|
||
storage.remove(albumId: albumID, photoId: photoID)
|
||
}
|
||
}
|
||
|
||
/// OTG 本地存储上下文,决定照片和索引的隔离目录。
|
||
struct TravelAlbumOTGStorageContext: Equatable, Sendable {
|
||
let accountCachePrefix: String
|
||
let userId: String
|
||
let scenicId: Int
|
||
let storeId: Int
|
||
|
||
/// 使用当前登录账号创建存储上下文。
|
||
static var current: TravelAlbumOTGStorageContext {
|
||
TravelAlbumOTGStorageContext(
|
||
accountCachePrefix: AppStore.shared.accountCachePrefix,
|
||
userId: AppStore.shared.userId,
|
||
scenicId: AppStore.shared.currentScenicId,
|
||
storeId: AppStore.shared.currentStoreId
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 旅拍相册 OTG 本地照片存储,负责路径隔离、索引读写与文件清理。
|
||
final class TravelAlbumOTGPhotoStore {
|
||
let context: TravelAlbumOTGStorageContext
|
||
private let fileManager: FileManager
|
||
private let applicationSupportDirectory: URL
|
||
private let cachesDirectory: URL
|
||
|
||
/// 初始化旅拍相册 OTG 存储。
|
||
init(
|
||
context: TravelAlbumOTGStorageContext = .current,
|
||
fileManager: FileManager = .default,
|
||
applicationSupportDirectory: URL? = nil,
|
||
cachesDirectory: URL? = nil
|
||
) {
|
||
self.context = context
|
||
self.fileManager = fileManager
|
||
self.applicationSupportDirectory = applicationSupportDirectory
|
||
?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
self.cachesDirectory = cachesDirectory
|
||
?? fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||
}
|
||
|
||
/// 读取相册照片记录。
|
||
func load(albumId: Int) -> [TravelAlbumOTGPhotoRecord] {
|
||
guard albumId > 0, !context.userId.isEmpty else { return [] }
|
||
guard let data = try? Data(contentsOf: indexURL(albumId: albumId)),
|
||
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
||
return []
|
||
}
|
||
return decoded
|
||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||
.map { $0.normalizedAfterInterruptedTransfer() }
|
||
.filter { isDisplayable($0, albumId: albumId) }
|
||
.sorted { $0.capturedAt > $1.capturedAt }
|
||
}
|
||
|
||
/// 保存相册照片记录。
|
||
func save(_ records: [TravelAlbumOTGPhotoRecord], albumId: Int) {
|
||
guard albumId > 0, !context.userId.isEmpty else { return }
|
||
let normalized = records
|
||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||
.map { recordByPersistingRelativePaths($0, albumId: albumId) }
|
||
.sorted { $0.capturedAt > $1.capturedAt }
|
||
do {
|
||
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
||
let data = try JSONEncoder().encode(normalized)
|
||
try data.write(to: indexURL(albumId: albumId), options: .atomic)
|
||
} catch {
|
||
OTGLog.error(.connection, "save OTG photo records failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
/// 插入或更新单条照片记录。
|
||
func upsert(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) {
|
||
var records = load(albumId: albumId).filter { $0.id != record.id }
|
||
records.append(record)
|
||
save(records, albumId: albumId)
|
||
}
|
||
|
||
/// 删除单张照片记录并清理本地文件。
|
||
func remove(albumId: Int, photoId: String) {
|
||
guard albumId > 0, !photoId.isEmpty else { return }
|
||
let records = load(albumId: albumId)
|
||
records.filter { $0.id == photoId }.forEach(deleteFiles)
|
||
save(records.filter { $0.id != photoId }, albumId: albumId)
|
||
}
|
||
|
||
/// 清理整个相册的 OTG 本地缓存。
|
||
func clearAlbum(albumId: Int) {
|
||
guard albumId > 0 else { return }
|
||
try? fileManager.removeItem(at: albumRoot(albumId: albumId))
|
||
try? fileManager.removeItem(at: previewRoot(albumId: albumId))
|
||
}
|
||
|
||
/// 原片目录。
|
||
func originalsDirectory(albumId: Int) throws -> URL {
|
||
let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true)
|
||
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||
try excludeFromBackup(url)
|
||
return url
|
||
}
|
||
|
||
/// 预览目录。
|
||
func previewsDirectory(albumId: Int) throws -> URL {
|
||
let url = previewRoot(albumId: albumId)
|
||
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||
return url
|
||
}
|
||
|
||
/// 在隔离原片目录下分配不冲突文件 URL。
|
||
func uniqueOriginalFileURL(filename: String, albumId: Int) throws -> URL {
|
||
let directory = try originalsDirectory(albumId: albumId)
|
||
let sanitized = PTPHelper.sanitizeFilename(filename)
|
||
let baseURL = directory.appendingPathComponent(sanitized)
|
||
if !fileManager.fileExists(atPath: baseURL.path) {
|
||
return baseURL
|
||
}
|
||
|
||
let stem = (sanitized as NSString).deletingPathExtension
|
||
let ext = (sanitized as NSString).pathExtension
|
||
var counter = 1
|
||
while counter < 10_000 {
|
||
let suffix = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)"
|
||
let candidate = directory.appendingPathComponent(suffix)
|
||
if !fileManager.fileExists(atPath: candidate.path) {
|
||
return candidate
|
||
}
|
||
counter += 1
|
||
}
|
||
throw CameraError.connectionFailed("无法分配唯一文件名")
|
||
}
|
||
|
||
/// 写入原片数据,返回隔离目录中的文件 URL。
|
||
func writeImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
|
||
let url = try uniqueOriginalFileURL(filename: filename, albumId: albumId)
|
||
try data.write(to: url, options: .atomic)
|
||
return url
|
||
}
|
||
|
||
/// 返回写入索引用的相对路径。
|
||
func relativePath(for url: URL, albumId: Int) -> String {
|
||
let path = url.standardizedFileURL.path
|
||
let originalRoot = albumRoot(albumId: albumId)
|
||
.appendingPathComponent("originals", isDirectory: true)
|
||
.standardizedFileURL
|
||
.path
|
||
if let relative = pathComponent(path, relativeTo: originalRoot) {
|
||
return "originals/\(relative)"
|
||
}
|
||
|
||
let previewPath = previewRoot(albumId: albumId).standardizedFileURL.path
|
||
if let relative = pathComponent(path, relativeTo: previewPath) {
|
||
return "previews/\(relative)"
|
||
}
|
||
|
||
return url.lastPathComponent
|
||
}
|
||
|
||
/// 把索引中的相对路径解析到当前沙盒绝对 URL。
|
||
func absoluteURL(for relativePath: String, albumId: Int) -> URL? {
|
||
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil }
|
||
if trimmed.hasPrefix("originals/") {
|
||
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||
}
|
||
if trimmed.hasPrefix("previews/") {
|
||
return previewRoot(albumId: albumId)
|
||
.deletingLastPathComponent()
|
||
.appendingPathComponent(trimmed)
|
||
}
|
||
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||
}
|
||
|
||
/// 判断索引相对路径对应的文件是否存在。
|
||
func fileExists(relativePath: String, albumId: Int) -> Bool {
|
||
guard let url = absoluteURL(for: relativePath, albumId: albumId) else { return false }
|
||
return fileManager.fileExists(atPath: url.path)
|
||
}
|
||
|
||
/// 返回可直接用于文件读取的记录副本。
|
||
func recordWithResolvedFilePaths(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> TravelAlbumOTGPhotoRecord {
|
||
var resolved = record
|
||
if let localURL = absoluteURL(for: record.localPath, albumId: albumId) {
|
||
resolved.localPath = localURL.path
|
||
}
|
||
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
|
||
resolved.thumbnailPath = thumbnailURL.path
|
||
}
|
||
return resolved
|
||
}
|
||
|
||
/// 生成稳定照片 ID。
|
||
static func photoId(for fileName: String, createdAt: Date) -> String {
|
||
let timestamp = Int64(createdAt.timeIntervalSince1970)
|
||
return "\(fileName.uppercased())_\(timestamp)"
|
||
}
|
||
|
||
/// Android 同款时间格式。
|
||
static func transferTimeText(_ date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
/// 格式化文件大小。
|
||
static func fileSizeText(_ bytes: Int64) -> String {
|
||
guard bytes > 0 else { return "--" }
|
||
let mb = Double(bytes) / 1024.0 / 1024.0
|
||
if mb >= 1024 {
|
||
return String(format: "%.2f GB", locale: Locale(identifier: "en_US_POSIX"), mb / 1024.0)
|
||
}
|
||
return String(format: "%.2f MB", locale: Locale(identifier: "en_US_POSIX"), mb)
|
||
}
|
||
|
||
private func albumRoot(albumId: Int) -> URL {
|
||
applicationSupportDirectory
|
||
.appendingPathComponent("TravelAlbumOTG", isDirectory: true)
|
||
.appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true)
|
||
.appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true)
|
||
.appendingPathComponent("store_\(context.storeId)", isDirectory: true)
|
||
.appendingPathComponent("album_\(albumId)", isDirectory: true)
|
||
}
|
||
|
||
private func previewRoot(albumId: Int) -> URL {
|
||
cachesDirectory
|
||
.appendingPathComponent("TravelAlbumOTG", isDirectory: true)
|
||
.appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true)
|
||
.appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true)
|
||
.appendingPathComponent("store_\(context.storeId)", isDirectory: true)
|
||
.appendingPathComponent("album_\(albumId)", isDirectory: true)
|
||
.appendingPathComponent("previews", isDirectory: true)
|
||
}
|
||
|
||
private func indexURL(albumId: Int) -> URL {
|
||
albumRoot(albumId: albumId).appendingPathComponent("records.json")
|
||
}
|
||
|
||
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
|
||
[record.localPath, record.thumbnailPath].forEach { path in
|
||
guard !path.isEmpty else { return }
|
||
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
|
||
try? fileManager.removeItem(at: url)
|
||
}
|
||
}
|
||
|
||
private func isDisplayable(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> Bool {
|
||
if record.status == .uploaded { return true }
|
||
if !record.remoteUrl.isEmpty { return true }
|
||
if fileExists(relativePath: record.localPath, albumId: albumId) { return true }
|
||
if fileExists(relativePath: record.thumbnailPath, albumId: albumId) { return true }
|
||
return false
|
||
}
|
||
|
||
private func recordByPersistingRelativePaths(
|
||
_ record: TravelAlbumOTGPhotoRecord,
|
||
albumId: Int
|
||
) -> TravelAlbumOTGPhotoRecord {
|
||
var normalized = record
|
||
if !record.localPath.isEmpty {
|
||
normalized.localPath = record.localPath.hasPrefix("/")
|
||
? relativePath(for: URL(fileURLWithPath: record.localPath), albumId: albumId)
|
||
: record.localPath
|
||
}
|
||
if !record.thumbnailPath.isEmpty {
|
||
normalized.thumbnailPath = record.thumbnailPath.hasPrefix("/")
|
||
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
|
||
: record.thumbnailPath
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
private func pathComponent(_ path: String, relativeTo root: String) -> String? {
|
||
let prefix = root.hasSuffix("/") ? root : "\(root)/"
|
||
guard path.hasPrefix(prefix) else { return nil }
|
||
return String(path.dropFirst(prefix.count))
|
||
}
|
||
|
||
private func excludeFromBackup(_ url: URL) throws {
|
||
var resourceValues = URLResourceValues()
|
||
resourceValues.isExcludedFromBackup = true
|
||
var mutableURL = url
|
||
try mutableURL.setResourceValues(resourceValues)
|
||
}
|
||
|
||
private func safePathComponent(_ value: String) -> String {
|
||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return "guest" }
|
||
let unsafe = CharacterSet(charactersIn: "/\\:?%*|\"<>")
|
||
return trimmed.unicodeScalars.reduce(into: "") { result, scalar in
|
||
result += unsafe.contains(scalar) ? "_" : String(scalar)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 兼容从 `otg_swift` 同步来的边拍边传写入调用。
|
||
enum AlbumPhotoStorage {
|
||
/// 写入原片数据到当前账号与相册隔离目录。
|
||
static func writeImage(_ data: Data, filename: String, albumID: Int) throws -> URL {
|
||
try TravelAlbumOTGPhotoStore().writeImage(data, filename: filename, albumId: albumID)
|
||
}
|
||
}
|