// // WiredCameraTransferViewModel.swift // 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 let scenicSpotLabel: String? private(set) var connectionState: ConnectionState = .idle { didSet { notifyStateChanged() } } private(set) var photos: [TravelAlbumOTGPhotoItem] = [] { didSet { normalizeSelectedTimeSlot() notifyStateChanged() } } private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { didSet { normalizeSelectedTimeSlot() notifyStateChanged() } } private(set) var selectedPhotoIds: Set = [] { didSet { notifyStateChanged() } } private(set) var selectUploadMode = false { didSet { notifyStateChanged() } } private(set) var sidebarExpanded = true { didSet { notifyStateChanged() } } private(set) var selectedTimeSlotId: String? { 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 transferMode: TravelAlbumOTGTransferMode = .liveUpload { didSet { notifyStateChanged() } } var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onLiveShotSaved: ((String) -> Void)? private let connectionManager: any WiredCameraConnectionManaging private let storage: TravelAlbumOTGPhotoStore 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 = [] private var suppressSelectedTimeSlotNotification = false @MainActor init( albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), uploader: (any TravelAlbumOTGUploading)? = nil, appStore: AppStore = .shared, userDefaults: UserDefaults = .standard ) { self.albumId = albumId self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.headerPhone = headerPhone self.scenicSpotLabel = scenicSpotLabel self.connectionManager = connectionManager ?? ConnectionManager.shared self.storage = storage self.repository = TravelAlbumOTGPhotoRepository(storage: storage) self.uploader = uploader ?? TravelAlbumOTGUploader() self.appStore = appStore self.userDefaults = userDefaults self.transferMode = Self.persistedTransferMode(in: userDefaults) } /// 相机状态文案。 var cameraStatusText: String { switch connectionState { case .idle, .disconnected: return "未连接 USB" case .searching: return "正在搜索" case .connecting: return "正在连接" case .connected(_, let name): return "\(name)已连接" case .failed: return "连接失败" } } /// 操作按钮文案。 var actionButtonText: String { switch connectionState { case .idle, .disconnected, .failed: return "重新连接" case .searching, .connecting: return "等待连接" case .connected: return "刷新" } } /// 当前手机型号名称。 var deviceModelName: String { Self.currentPhoneModelName() } /// 当前手机可用存储空间。 var availableStorageText: String { Self.availableStorageText() } /// 是否已连接相机。 var isCameraConnected: Bool { if case .connected = connectionState { return true } return false } /// Tab 统计。 var tabCounts: [Int] { [ photos.count, photos.filter { $0.status == .uploaded }.count, photos.filter { $0.status == .failed }.count, ] } /// 过滤后的照片列表。 func filteredPhotos() -> [TravelAlbumOTGPhotoItem] { switch selectedTab { case .all: return photos case .uploaded: return photos.filter { $0.status == .uploaded } case .failed: return photos.filter { $0.status == .failed } } } /// 过滤后照片的半小时时间分段。 var photoSections: [TravelAlbumOTGPhotoSection] { filteredPhotos().buildPhotoSections() } /// 过滤后照片的左侧时间导航分组。 var sidebarGroups: [TravelAlbumOTGDateGroup] { filteredPhotos().buildSidebarGroups() } /// 当前可见列表中可被选择上传的照片 ID。 var selectableVisiblePhotoIds: Set { Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id)) } /// 当前可见列表中是否已全选可上传照片。 var allVisibleSelectablePhotosSelected: Bool { let ids = selectableVisiblePhotoIds return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds) } /// 当前可见列表中已选择的可上传照片数量。 var selectedVisibleUploadCount: Int { selectedPhotoIds.intersection(selectableVisiblePhotoIds).count } /// 传输模式展示文案。 var transferModeOption: String { transferMode.title } /// 指定上传弹窗选项。 var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { TravelAlbumOTGSpecifyUploadOption.allCases } /// 是否可打开指定上传选项。 var canOpenSpecifyUploadChooser: Bool { !selectUploadMode } /// 绑定连接管理器并启动发现。 func start() { connectionManager.configureLiveTransfer(albumID: albumId) connectionManager.delegate = self syncFromConnectionManager() loadPersistedPhotos() connectionManager.start() } /// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。 func stop() { connectionManager.unbindDelegate() connectionManager.suspendLiveTransfer() } /// 主动断开相机连接。 func disconnect() { connectionManager.disconnect() } /// 刷新相机文件列表或重新连接。 func refreshCameraFiles() { switch connectionState { case .connected: loadPersistedPhotos() case .idle, .disconnected, .failed: connectionManager.start() default: break } } /// 重新加载当前相册本地 OTG 照片。 func reloadLocalPhotos() { loadPersistedPhotos() } /// 切换 Tab。 func selectTab(_ tab: TravelAlbumOTGTransferTab) { selectedTab = tab selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id))) } /// 展开或收起左侧时间快速导航。 func toggleSidebar() { sidebarExpanded.toggle() } /// 选择左侧时间槽,并联动主列表滚动。 func selectTimeSlot(id: String) { guard photoSections.contains(where: { $0.slotId == id }) else { return } 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 let mode = TravelAlbumOTGTransferMode.option(title: option), mode != transferMode else { return } transferMode = mode userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) } /// 切换上传格式。 func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { guard photoFormatOption != option else { return } photoFormatOption = option applyMergedPhotos() } /// 批量上传按钮:首次进入选择模式,再次上传选中照片。 func onBatchUploadButtonClick() { if !selectUploadMode { selectUploadMode = true selectedPhotoIds = [] return } if selectedPhotoIds.isEmpty { selectUploadMode = false return } uploadSelectedTransferPhotos() } /// 选择指定上传选项后开始上传。 func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) { guard canOpenSpecifyUploadChooser else { return } switch option { case .allPending: uploadAllNotUploadedPhotos() case .todayCaptured: uploadAllTodayCapturedPhotos() } } /// 创建相机历史照片选择导入页 ViewModel。 func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? { guard isContentCatalogReady else { showMessage("相机照片目录尚未就绪,请稍候再试") return nil } guard let currentDriver else { showMessage("请先连接相机") return nil } if case .connected = connectionState { return TravelAlbumCameraImportViewModel( albumId: albumId, albumTitle: albumTitle, driver: currentDriver, storage: storage, appStore: appStore ) } showMessage("请先连接相机") return nil } /// 历史导入期间暂停边拍边传轮询,避免 PTP 轮询与 ImageCaptureCore 下载互相抢占。 func pauseLiveTransferForHistoricalImport() { 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.session.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, photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else { return } if selectedPhotoIds.contains(photoId) { selectedPhotoIds.remove(photoId) } else { selectedPhotoIds.insert(photoId) } } /// 切换当前可见照片的全选状态。 func toggleVisibleSelectablePhotos() { guard selectUploadMode else { return } let ids = selectableVisiblePhotoIds guard !ids.isEmpty else { return } if ids.isSubset(of: selectedPhotoIds) { selectedPhotoIds.subtract(ids) } else { selectedPhotoIds.formUnion(ids) } } /// 上传选中照片。 func uploadSelectedTransferPhotos() { let ids = selectedPhotoIds guard !ids.isEmpty else { showMessage("请先选择照片") return } selectUploadMode = false selectedPhotoIds = [] 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: "本地文件不存在,无法重传") } /// 删除单张本地照片。 func deletePhoto(photoId: String) { storage.remove(albumId: albumId, photoId: photoId) persistedRecordsById.removeValue(forKey: photoId) selectedPhotoIds.remove(photoId) applyMergedPhotos() showMessage("已删除") } /// 清空当前相册本地 OTG 缓存。 func clearLocalAlbumCache() { storage.clearAlbum(albumId: albumId) persistedRecordsById = [:] photos = [] } private func syncFromConnectionManager() { currentDriver = connectionManager.currentDriver isContentCatalogReady = connectionManager.isContentCatalogReady connectionState = connectionManager.state } private func loadPersistedPhotos() { let records = storage.load(albumId: albumId) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) applyMergedPhotos() } private func applyMergedPhotos() { let persistedItems = persistedRecordsById.values .map { $0.toPhotoItem(storage: storage, albumId: albumId) } photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } } private func normalizeSelectedTimeSlot() { let sections = filteredPhotos().buildPhotoSections() guard let firstSlotId = sections.first?.slotId else { selectedTimeSlotId = nil return } if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) { selectedTimeSlotId = firstSlotId } } private func startUploadByIds( _ ids: Set, emptyMessage: String, queueIfBusy: Bool = false ) { guard !isUploading else { if queueIfBusy { queuedAutoUploadPhotoIds.formUnion(ids) } else { showMessage("正在上传,请稍候") } return } let targets = resolveUploadTargets(ids) guard !targets.isEmpty else { showMessage(emptyMessage) return } isUploading = true 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] { 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 { do { let record = try await localRecordForUpload(id: id) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) let material = try await uploader.upload( record: record, scenicId: appStore.session.currentScenicId ) { [weak self] progress in self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) } updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) } catch { updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) showMessage(error.localizedDescription) } } private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { if let record = persistedRecordsById[id], !record.localPath.isEmpty, storage.fileExists(relativePath: record.localPath, albumId: albumId) { return storage.recordWithResolvedFilePaths(record, albumId: albumId) } throw TravelAlbumOTGUploadError.localFileMissing } private func updateRecord( id: String, status: TravelAlbumOTGUploadStatus, progress: Int, error: String?, remoteUrl: String? = nil ) { var record = persistedRecordsById[id] if record == nil, let item = photos.first(where: { $0.id == id }) { record = TravelAlbumOTGPhotoRecord( id: item.id, sourceId: item.sourceId, fileName: item.fileName, localPath: item.localPath, thumbnailPath: item.thumbnailURL?.isFileURL == true ? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId) : "", capturedAt: item.capturedAt, fileSizeBytes: item.fileSizeBytes, status: status, progress: progress, errorMessage: error, albumId: albumId, userId: appStore.session.userId, remoteUrl: remoteUrl ?? item.remoteUrl ) } guard var record else { return } record.status = status record.progress = min(max(progress, 0), 100) record.errorMessage = error if let remoteUrl { record.remoteUrl = remoteUrl } record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) persistedRecordsById[id] = record storage.upsert(record, albumId: albumId) applyMergedPhotos() } private func notifyStateChanged() { if Thread.isMainThread { onStateChange?() } else { DispatchQueue.main.async { [weak self] in self?.onStateChange?() } } } 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) } else { DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) } } } } extension WiredCameraTransferViewModel: ConnectionManagerDelegate { func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) { connectionState = state } func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) { currentDriver = driver } func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) { currentDriver = driver isContentCatalogReady = true notifyStateChanged() } func connectionManager(_ manager: ConnectionManager, didFail error: any Error) { showMessage(error.localizedDescription) } func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) { showMessage("长时间未找到相机,请尝试重新插拔连接线") } func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) { guard record.albumId == albumId else { return } handleNewlyImportedPhotoIds([record.id]) onLiveShotSaved?(record.fileName) } }