feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,386 @@
//
// 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
}
///
var isDisplayable: Bool {
if status == .uploaded { return true }
if !remoteUrl.isEmpty { return true }
if !localPath.isEmpty, FileManager.default.fileExists(atPath: localPath) { return true }
if !thumbnailPath.isEmpty, FileManager.default.fileExists(atPath: thumbnailPath) { return true }
return false
}
///
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: localPath,
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 []
}
let deletedIds = loadDeletedIds(albumId: albumId)
return decoded
.filter { $0.albumId == albumId && $0.userId == context.userId && !deletedIds.contains($0.id) }
.map { $0.normalizedAfterInterruptedTransfer() }
.filter(\.isDisplayable)
.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 }
.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)
saveDeletedIds(loadDeletedIds(albumId: albumId).union([photoId]), albumId: albumId)
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))
}
/// ID
func loadDeletedIds(albumId: Int) -> Set<String> {
guard let data = try? Data(contentsOf: deletedIndexURL(albumId: albumId)),
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return Set(decoded)
}
///
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
}
/// 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 deletedIndexURL(albumId: Int) -> URL {
albumRoot(albumId: albumId).appendingPathComponent("deleted.json")
}
private func saveDeletedIds(_ ids: Set<String>, albumId: Int) {
do {
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(Array(ids))
try data.write(to: deletedIndexURL(albumId: albumId), options: .atomic)
} catch {
OTGLog.warning(.connection, "save deleted OTG ids failed: \(error.localizedDescription)")
}
}
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
[record.localPath, record.thumbnailPath].forEach { path in
guard !path.isEmpty else { return }
try? fileManager.removeItem(atPath: path)
}
}
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)
}
}