将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
8.8 KiB
Swift
222 lines
8.8 KiB
Swift
//
|
||
// CameraDownloadStorage.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
import ImageIO
|
||
import UniformTypeIdentifiers
|
||
|
||
/// 有线传图本地文件存储,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||
enum CameraDownloadStorage {
|
||
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 根目录。
|
||
static var documentsDirectory: URL {
|
||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
}
|
||
|
||
/// 有线传图照片下载根目录。
|
||
static var downloadsDirectory: URL {
|
||
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
return directory
|
||
}
|
||
|
||
/// 当前账号与相册的下载根目录。
|
||
static func scopeDirectory(for scope: Scope) -> URL {
|
||
let directory = downloadsDirectory
|
||
.appendingPathComponent(scope.accountDirectoryName, isDirectory: true)
|
||
.appendingPathComponent(scope.albumDirectoryName, isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
return directory
|
||
}
|
||
|
||
/// 当前账号与相册的原图目录。
|
||
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 的路径。
|
||
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? {
|
||
guard !storedPath.isEmpty else { return nil }
|
||
|
||
if storedPath.hasPrefix("http://") || storedPath.hasPrefix("https://") {
|
||
return URL(string: storedPath)
|
||
}
|
||
|
||
if storedPath.hasPrefix("/") {
|
||
return URL(fileURLWithPath: storedPath)
|
||
}
|
||
|
||
return documentsDirectory.appendingPathComponent(storedPath)
|
||
}
|
||
|
||
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||
let timestamp = Int(Date().timeIntervalSince1970)
|
||
let safeName = sanitizeFilename(filename)
|
||
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||
}
|
||
|
||
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
||
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
||
let safeName = sanitizeFilename(assetID)
|
||
.replacingOccurrences(of: ".", with: "_")
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 将文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||
static func sanitizeFilename(_ filename: String) -> String {
|
||
let trimmed = filename
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.replacingOccurrences(of: "/", with: "_")
|
||
.replacingOccurrences(of: ":", with: "_")
|
||
|
||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-")
|
||
}
|
||
|
||
var name = String(String.UnicodeScalarView(ascii))
|
||
if name.isEmpty {
|
||
name = "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||
}
|
||
if !name.contains(".") {
|
||
name += ".JPG"
|
||
}
|
||
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 字符串。
|
||
static func previewURLString(from storedPath: String) -> String {
|
||
guard let url = resolveLocalURL(from: storedPath),
|
||
FileManager.default.fileExists(atPath: url.path) else {
|
||
return ""
|
||
}
|
||
return url.absoluteString
|
||
}
|
||
|
||
/// 后台读取文件大小,避免在列表刷新路径上阻塞主线程。
|
||
static func fileSizeBytes(for url: URL?) async -> Int64 {
|
||
guard let url else { return 0 }
|
||
return await Task.detached(priority: .utility) {
|
||
(try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
|
||
}.value
|
||
}
|
||
}
|
||
|
||
/// 本地照片缩略图生成器,负责为列表生成小尺寸预览图。
|
||
enum CameraThumbnailGenerator {
|
||
private static let maxPixelSize: CGFloat = 160
|
||
|
||
/// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。
|
||
static func generateThumbnail(for imageURL: URL, assetID: String, scope: CameraDownloadStorage.Scope) async -> String? {
|
||
await Task.detached(priority: .utility) {
|
||
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||
let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
|
||
return nil
|
||
}
|
||
|
||
let options: [CFString: Any] = [
|
||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||
kCGImageSourceShouldCacheImmediately: false,
|
||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
|
||
]
|
||
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||
return nil
|
||
}
|
||
|
||
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID, scope: scope)
|
||
if FileManager.default.fileExists(atPath: destination.path) {
|
||
try? FileManager.default.removeItem(at: destination)
|
||
}
|
||
guard let imageDestination = CGImageDestinationCreateWithURL(
|
||
destination as CFURL,
|
||
UTType.jpeg.identifier as CFString,
|
||
1,
|
||
nil
|
||
) else {
|
||
return nil
|
||
}
|
||
|
||
CGImageDestinationAddImage(imageDestination, thumbnail, [
|
||
kCGImageDestinationLossyCompressionQuality: 0.78
|
||
] as CFDictionary)
|
||
|
||
guard CGImageDestinationFinalize(imageDestination) else { return nil }
|
||
return CameraDownloadStorage.relativePath(for: destination)
|
||
}.value
|
||
}
|
||
}
|