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