@ -9,10 +9,30 @@ 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 ) ) "
}
}
// / D o c u m e n t s 根 目 录 。
static var documentsDirectory : URL {
FileManager . default . urls ( for : . documentDirectory , in : . userDomainMask ) [ 0 ]
@ -25,24 +45,56 @@ enum CameraDownloadStorage {
return directory
}
// / 相 机 照 片 列 表 缩 略 图 目 录。
static var thumbnail sDirectory : URL {
let directory = downloadsDirectory . appendingPathComponent ( " Thumbnails " , isDirectory : true )
// / 相 机 底 层 下 载 临 时 目 录, 最 终 业 务 记 录 不 会 持 久 化 这 里 的 路 径 。
static var temporaryDownload sDirectory : URL {
let directory = FileManager . default . temporaryDirectory
. appendingPathComponent ( cameraDownloadsFolderName , isDirectory : true )
try ? FileManager . default . createDirectory ( at : directory , withIntermediateDirectories : true )
return directory
}
// / 将 绝 对 路 径 转 为 相 对 D o c u m e n t s 的 路 径 , 如 ` C a m e r a D o w n l o a d s / 1 7 3 0 _ D S C . J P G ` 。
static func relativePath ( for url : URL ) -> String {
let standardizedURL = url . standardizedFileURL
let thumbnailsPath = thumbnailsDirectory . standardizedFileURL . path
if standardizedURL . path . hasPrefix ( thumbnailsPath ) {
return " \( cameraDownloadsFolderName ) /Thumbnails/ \( url . lastPathComponent ) "
}
return " \( cameraDownloadsFolderName ) / \( url . lastPathComponent ) "
// / 当 前 账 号 与 相 册 的 下 载 根 目 录 。
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
}
// / 将 持 久 化 的 相 对 或 旧 版 绝 对 路 径 解 析 为 当 前 沙 盒 内 的 文 件 U R L 。
// / 当 前 账 号 与 相 册 的 原 图 目 录 。
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
}
// / 将 绝 对 路 径 转 为 相 对 D o c u m e n t s 的 路 径 , 如 ` C a m e r a D o w n l o a d s / u s e r _ 1 0 1 / 6 6 0 3 / o r i g i n a l s / 1 7 3 0 _ D S C . J P G ` 。
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
}
// / 将 持 久 化 的 相 对 路 径 、 绝 对 路 径 或 网 络 地 址 解 析 为 可 访 问 U R L 。
static func resolveLocalURL ( from storedPath : String ) -> URL ? {
guard ! storedPath . isEmpty else { return nil }
@ -51,56 +103,46 @@ enum CameraDownloadStorage {
}
if storedPath . hasPrefix ( " / " ) {
let legacyURL = URL ( fileURLWithPath : storedPath )
if FileManager . default . fileExists ( atPath : legacyURL . path ) {
return legacyURL
}
let candidate = downloadsDirectory . appendingPathComponent ( legacyURL . lastPathComponent )
if FileManager . default . fileExists ( atPath : candidate . path ) {
return candidate
}
return legacyURL
return URL ( fileURLWithPath : storedPath )
}
return documentsDirectory . appendingPathComponent ( storedPath )
}
// / 将 旧 版 绝 对 路 径 迁 移 为 相 对 路 径 ; 若 文 件 已 在 当 前 C a m e r a D o w n l o a d s 中 则 更 新 引 用 。
static func migrateStoredPath ( _ storedPath : String ) -> String ? {
guard storedPath . hasPrefix ( " / " ) else { return storedPath }
let legacyURL = URL ( fileURLWithPath : storedPath )
if FileManager . default . fileExists ( atPath : legacyURL . path ) {
return relativePath ( for : legacyURL )
}
let candidate = downloadsDirectory . appendingPathComponent ( legacyURL . lastPathComponent )
if FileManager . default . fileExists ( atPath : candidate . path ) {
return relativePath ( for : candidate )
}
return relativePath ( for : legacyURL )
}
// / 判 断 文 件 是 否 位 于 C a m e r a D o w n l o a d s 目 录 内 。
static func isInDownloadsDirectory ( _ url : URL ) -> Bool {
let downloadsPath = downloadsDirectory . standardizedFileURL . path
// / 判 断 文 件 是 否 位 于 当 前 账 号 与 相 册 的 原 图 目 录 内 。
static func isInOriginalsDirectory ( _ url : URL , scope : Scope ) -> Bool {
let originalsPath = originalsDirectory ( for : scope ) . standardizedFileURL . path
let filePath = url . standardizedFileURL . path
return filePath . hasPrefix ( downloadsPath ) || filePath . hasPrefix ( " /private " + downloadsPath )
return filePath . hasPrefix ( originalsPath + " / " ) || filePath . hasPrefix ( " /private " + originalsPath + " / " )
}
// / 生 成 带 时 间 戳 的 唯 一 本 地 文 件 U R L , 避 免 文 件 名 冲 突 。
static func uniqueLocalURL ( for filename : String ) -> URL {
// / 生 成 当 前 账 号 与 相 册 内 带时 间 戳 的 唯 一 原 图 U R L , 避 免 文 件 名 冲 突 。
static func uniqueLocalURL ( for filename : String , scope : Scope ) -> URL {
let timestamp = Int ( Date ( ) . timeIntervalSince1970 )
let safeName = sanitizeFilename ( filename )
return download sDirectory. appendingPathComponent ( " \( timestamp ) _ \( safeName ) " )
return original sDirectory( for : scope ) .appendingPathComponent ( " \( timestamp ) _ \( safeName ) " )
}
// / 生 成 带 a s s e t I D 的 唯 一 缩 略 图 文 件 U R L 。
static func uniqueThumbnail URL ( for assetID : String ) -> URL {
// / 生 成 相 机 底 层 临 时 下 载 U R L 。
static func uniqueTemporary URL ( for filename : String ) -> URL {
let timestamp = Int ( Date ( ) . timeIntervalSince1970 )
let safeName = sanitizeFilename ( filename )
return temporaryDownloadsDirectory . appendingPathComponent ( " \( timestamp ) _ \( safeName ) " )
}
// / 生 成 当 前 账 号 与 相 册 内 带 a s s e t I D 的 唯 一 缩 略 图 文 件 U R L 。
static func uniqueThumbnailURL ( for assetID : String , scope : Scope ) -> URL {
let safeName = sanitizeFilename ( assetID )
. replacingOccurrences ( of : " . " , with : " _ " )
return thumbnailsDirectory . appendingPathComponent ( " \( safeName ) _thumb.jpg " )
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 )
}
}
// / 将 相 机 P T P 文 件 名 转 为 i O S 文 件 系 统 可 安 全 使 用 的 A S C I I 名 称 。
@ -124,6 +166,21 @@ enum CameraDownloadStorage {
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
}
// / 将 本 地 存 储 路 径 转 为 可 用 于 U I 预 览 的 f i l e U R L 字 符 串 。
static func previewURLString ( from storedPath : String ) -> String {
guard let url = resolveLocalURL ( from : storedPath ) ,
@ -147,7 +204,7 @@ enum CameraThumbnailGenerator {
private static let maxPixelSize : CGFloat = 160
// / 为 本 地 照 片 生 成 列 表 缩 略 图 , 成 功 时 返 回 相 对 D o c u m e n t s 的 持 久 化 路 径 。
static func generateThumbnail ( for imageURL : URL , assetID : String ) async -> String ? {
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 {
@ -164,7 +221,7 @@ enum CameraThumbnailGenerator {
return nil
}
let destination = CameraDownloadStorage . uniqueThumbnailURL ( for : assetID )
let destination = CameraDownloadStorage . uniqueThumbnailURL ( for : assetID , scope : scope )
if FileManager . default . fileExists ( atPath : destination . path ) {
try ? FileManager . default . removeItem ( at : destination )
}