Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Services/CameraDownloadStorage.swift
汉秋 d7fec35715 移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:55:40 +08:00

222 lines
8.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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