移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,221 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user