Update OTG transfer workflow

This commit is contained in:
2026-07-10 11:02:00 +08:00
parent 9d446b4bc3
commit 98dc810455
11 changed files with 1047 additions and 121 deletions

View File

@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable {
case loading
case loaded(sections: [TravelAlbumCameraImportSection])
case importing(current: Int, total: Int)
case finished(importedCount: Int, failedCount: Int)
case finished(importedCount: Int, failedCount: Int, importedPhotoIds: [String])
case failed(message: String)
}
@ -180,6 +180,7 @@ final class TravelAlbumCameraImportViewModel {
Task {
let directory = try? storage.originalsDirectory(albumId: albumId)
var importedCount = 0
var importedPhotoIds: [String] = []
for (index, object) in selected.enumerated() {
defer { state = .importing(current: index + 1, total: selected.count) }
@ -204,6 +205,7 @@ final class TravelAlbumCameraImportViewModel {
)
storage.upsert(record, albumId: albumId)
existingPhotoIds.insert(object.id)
importedPhotoIds.append(record.id)
importedCount += 1
} catch {
failedPhotoIds.insert(object.id)
@ -212,7 +214,11 @@ final class TravelAlbumCameraImportViewModel {
}
selectedPhotoIds.subtract(existingPhotoIds)
state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count)
state = .finished(
importedCount: importedCount,
failedCount: failedPhotoIds.count,
importedPhotoIds: importedPhotoIds
)
}
}

View File

@ -3,11 +3,28 @@
// suixinkan
//
import Darwin
import Foundation
/// OTG
struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
let data: Data
let fileName: String
let createdAt: Date
///
init(data: Data, fileName: String, createdAt: Date = Date()) {
self.data = data
self.fileName = fileName
self.createdAt = createdAt
}
}
/// 线 ViewModel ImageCaptureCore
@MainActor
final class WiredCameraTransferViewModel {
private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode"
let albumId: Int
let albumTitle: String
let headerPhone: String
@ -38,14 +55,19 @@ final class WiredCameraTransferViewModel {
didSet { notifyStateChanged() }
}
private(set) var selectedTimeSlotId: String? {
didSet { notifyStateChanged() }
didSet {
guard !suppressSelectedTimeSlotNotification else { return }
notifyStateChanged()
}
}
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferModeOption = "边拍边传"
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
}
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -56,10 +78,13 @@ final class WiredCameraTransferViewModel {
private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading
private let appStore: AppStore
private let userDefaults: UserDefaults
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
private var isUploading = false
private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false
@MainActor
init(
@ -70,7 +95,8 @@ final class WiredCameraTransferViewModel {
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
appStore: AppStore = .shared
appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@ -81,6 +107,8 @@ final class WiredCameraTransferViewModel {
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader()
self.appStore = appStore
self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults)
}
///
@ -92,8 +120,8 @@ final class WiredCameraTransferViewModel {
return "正在搜索"
case .connecting:
return "正在连接"
case .connected:
return "已连接"
case .connected(_, let name):
return "\(name)已连接"
case .failed:
return "连接失败"
}
@ -111,19 +139,22 @@ final class WiredCameraTransferViewModel {
}
}
///
///
var deviceModelName: String {
switch connectionState {
case .connecting(let name), .connected(_, let name):
return name
default:
return "设备存储"
}
Self.currentPhoneModelName()
}
///
///
var availableStorageText: String {
"-- GB 可用"
Self.availableStorageText()
}
///
var isCameraConnected: Bool {
if case .connected = connectionState {
return true
}
return false
}
/// Tab
@ -173,6 +204,21 @@ final class WiredCameraTransferViewModel {
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
}
///
var transferModeOption: String {
transferMode.title
}
///
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
}
///
var canOpenSpecifyUploadChooser: Bool {
!selectUploadMode
}
///
func start() {
connectionManager.configureLiveTransfer(albumID: albumId)
@ -227,10 +273,25 @@ final class WiredCameraTransferViewModel {
selectedTimeSlotId = id
}
///
func syncVisibleTimeSlot(id: String) {
guard selectedTimeSlotId != id,
photoSections.contains(where: { $0.slotId == id }) else {
return
}
suppressSelectedTimeSlotNotification = true
selectedTimeSlotId = id
suppressSelectedTimeSlotNotification = false
}
///
func selectTransferMode(_ option: String) {
guard option == "边拍边传" || option == "拍后传输" else { return }
transferModeOption = option
guard let mode = TravelAlbumOTGTransferMode.option(title: option),
mode != transferMode else {
return
}
transferMode = mode
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
///
@ -254,10 +315,15 @@ final class WiredCameraTransferViewModel {
uploadSelectedTransferPhotos()
}
///
func onSpecifyUploadButtonClick() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
///
func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) {
guard canOpenSpecifyUploadChooser else { return }
switch option {
case .allPending:
uploadAllNotUploadedPhotos()
case .todayCaptured:
uploadAllTodayCapturedPhotos()
}
}
/// ViewModel
@ -288,6 +354,49 @@ final class WiredCameraTransferViewModel {
connectionManager.suspendLiveTransfer()
}
/// App
func handleNewlyImportedPhotoIds(_ photoIds: [String]) {
let ids = Set(photoIds)
loadPersistedPhotos()
guard !ids.isEmpty, transferMode.shouldAutoUploadNewImports else { return }
startUploadByIds(
ids,
emptyMessage: "暂无可自动上传的照片",
queueIfBusy: true
)
}
/// OTG
func importPhoneAlbumImages(_ items: [TravelAlbumPhoneAlbumImportItem]) -> (importedCount: Int, failedCount: Int, importedPhotoIds: [String]) {
guard !items.isEmpty else { return (0, 0, []) }
var importedPhotoIds: [String] = []
var failedCount = 0
for item in items {
do {
let fileURL = try storage.writeImage(item.data, filename: item.fileName, albumId: albumId)
let record = TravelAlbumOTGPhotoRecord(
id: "phone_album_\(UUID().uuidString)",
fileName: fileURL.lastPathComponent,
localPath: storage.relativePath(for: fileURL, albumId: albumId),
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(item.createdAt),
fileSizeBytes: Int64(item.data.count),
status: .pending,
albumId: albumId,
userId: appStore.userId
)
storage.upsert(record, albumId: albumId)
importedPhotoIds.append(record.id)
} catch {
failedCount += 1
OTGLog.error(.connection, "phone album import failed: \(item.fileName) \(error.localizedDescription)")
}
}
handleNewlyImportedPhotoIds(importedPhotoIds)
return (importedPhotoIds.count, failedCount, importedPhotoIds)
}
///
func toggleTransferPhotoSelection(photoId: String) {
guard selectUploadMode,
@ -325,6 +434,22 @@ final class WiredCameraTransferViewModel {
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
}
private func uploadAllNotUploadedPhotos() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
}
private func uploadAllTodayCapturedPhotos() {
let ids = Set(photos.filter { photo in
guard photo.isNotUploaded,
let capturedDate = photo.capturedDate() else {
return false
}
return Calendar.current.isDateInToday(capturedDate)
}.map(\.id))
startUploadByIds(ids, emptyMessage: "暂无今日拍摄且未上传的照片")
}
///
func retryPhoto(photoId: String) {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
@ -375,9 +500,17 @@ final class WiredCameraTransferViewModel {
}
}
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
private func startUploadByIds(
_ ids: Set<String>,
emptyMessage: String,
queueIfBusy: Bool = false
) {
guard !isUploading else {
showMessage("正在上传,请稍候")
if queueIfBusy {
queuedAutoUploadPhotoIds.formUnion(ids)
} else {
showMessage("正在上传,请稍候")
}
return
}
let targets = resolveUploadTargets(ids)
@ -386,18 +519,32 @@ final class WiredCameraTransferViewModel {
return
}
isUploading = true
showMessage("开始上传 \(targets.count) 张照片")
Task {
for id in targets {
await uploadPhoto(id: id)
}
isUploading = false
notifyStateChanged()
uploadQueuedAutoImportsIfNeeded()
}
}
private func uploadQueuedAutoImportsIfNeeded() {
let ids = queuedAutoUploadPhotoIds
queuedAutoUploadPhotoIds = []
guard !ids.isEmpty else { return }
startUploadByIds(
ids,
emptyMessage: "暂无可自动上传的照片",
queueIfBusy: true
)
}
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
ids.filter { persistedRecordsById[$0] != nil }
ids.filter { id in
guard let record = persistedRecordsById[id] else { return false }
return record.status == .pending || record.status == .failed
}
}
private func uploadPhoto(id: String) async {
@ -474,6 +621,88 @@ final class WiredCameraTransferViewModel {
}
}
private static func persistedTransferMode(in userDefaults: UserDefaults) -> TravelAlbumOTGTransferMode {
guard let rawValue = userDefaults.string(forKey: transferModeDefaultsKey),
let mode = TravelAlbumOTGTransferMode(rawValue: rawValue) else {
return .liveUpload
}
return mode
}
private static func currentPhoneModelName() -> String {
let identifier = machineIdentifier()
if identifier == "i386" || identifier == "x86_64" || identifier == "arm64" {
return "iPhone 模拟器"
}
if let modelName = iPhoneModelNames[identifier] {
return modelName
}
return identifier.hasPrefix("iPhone") ? identifier : "iPhone"
}
private static func machineIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) { pointer in
pointer.withMemoryRebound(to: CChar.self, capacity: 1) {
String(validatingUTF8: $0) ?? "iPhone"
}
}
}
private static func availableStorageText() -> String {
guard let bytes = availableStorageBytes() else {
return "-- GB 可用"
}
let gigabytes = Double(bytes) / 1_073_741_824.0
if gigabytes >= 100 {
return "\(Int(gigabytes.rounded(.down))) GB 可用"
}
return String(format: "%.1f GB 可用", gigabytes)
}
private static func availableStorageBytes() -> Int64? {
let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
if let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
let capacity = values.volumeAvailableCapacityForImportantUsage {
return capacity
}
if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
let freeSize = attributes[.systemFreeSize] as? NSNumber {
return freeSize.int64Value
}
return nil
}
private static let iPhoneModelNames: [String: String] = [
"iPhone12,1": "iPhone 11",
"iPhone12,3": "iPhone 11 Pro",
"iPhone12,5": "iPhone 11 Pro Max",
"iPhone12,8": "iPhone SE 2",
"iPhone13,1": "iPhone 12 mini",
"iPhone13,2": "iPhone 12",
"iPhone13,3": "iPhone 12 Pro",
"iPhone13,4": "iPhone 12 Pro Max",
"iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,6": "iPhone SE 3",
"iPhone14,7": "iPhone 14",
"iPhone14,8": "iPhone 14 Plus",
"iPhone15,2": "iPhone 14 Pro",
"iPhone15,3": "iPhone 14 Pro Max",
"iPhone15,4": "iPhone 15",
"iPhone15,5": "iPhone 15 Plus",
"iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone17,5": "iPhone 16e",
]
private func showMessage(_ message: String) {
if Thread.isMainThread {
onShowMessage?(message)
@ -507,9 +736,9 @@ extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
showMessage("长时间未找到相机,请尝试重新插拔连接线")
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
guard albumID == albumId else { return }
loadPersistedPhotos()
onLiveShotSaved?(filename)
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {
guard record.albumId == albumId else { return }
handleNewlyImportedPhotoIds([record.id])
onLiveShotSaved?(record.fileName)
}
}