Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift

785 lines
27 KiB
Swift
Raw Permalink 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.

//
// 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<String> = [] {
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 api: any TravelAlbumServing
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
private var serverStatusSyncTask: Task<Void, Never>?
@MainActor
init(
albumId: Int,
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = 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.api = api ?? NetworkServices.shared.travelAlbumAPI
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<String> {
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()
syncServerUploadStatuses()
connectionManager.start()
}
/// UI delegate
func stop() {
serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil
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 syncServerUploadStatuses() {
serverStatusSyncTask?.cancel()
guard albumId > 0 else { return }
let baseline = persistedRecordsById
serverStatusSyncTask = Task { [weak self] in
guard let self else { return }
do {
let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId)
try Task.checkCancellation()
let activeIds = Set(persistedRecordsById.values.compactMap { record in
record.status == .transferring || record.status == .uploading ? record.id : nil
})
let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile(
records: Array(persistedRecordsById.values),
baselineRecordsById: baseline,
activePhotoIds: activeIds,
serverClientPhotoIds: response.clientPhotoIds,
now: Int64(Date().timeIntervalSince1970 * 1000)
)
let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) })
guard reconciledById != persistedRecordsById else { return }
persistedRecordsById = reconciledById
storage.save(reconciled, albumId: albumId)
applyMergedPhotos()
selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds)
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)")
}
}
}
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<String>,
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>) -> [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)
}
}