移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。

将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 10:55:40 +08:00
parent a0549e068a
commit d7fec35715
42 changed files with 187 additions and 5278 deletions

View File

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