Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift

439 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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