Update OTG transfer workflow
This commit is contained in:
@ -15,8 +15,8 @@ final class CanonRemoteCaptureService {
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||
|
||||
private var eventPollTimer: Timer?
|
||||
private var catalogPollTimer: Timer?
|
||||
@ -175,14 +175,14 @@ final class CanonRemoteCaptureService {
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
guard let record = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(filename)
|
||||
onShotSaved?(record)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
@ -417,23 +417,23 @@ final class CanonRemoteCaptureService {
|
||||
lastDownloadedSignature = signature
|
||||
knownDownloadedHandles.insert(item.handle)
|
||||
|
||||
guard await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
||||
guard let record = await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
||||
return false
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)")
|
||||
onShotSaved?(result.filename)
|
||||
onShotSaved?(record)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
@ -442,15 +442,14 @@ final class CanonRemoteCaptureService {
|
||||
filename: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
return try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,8 @@ final class NikonCatalogLiveCaptureService {
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
@ -140,14 +141,14 @@ final class NikonCatalogLiveCaptureService {
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
guard let record = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(filename)
|
||||
onShotSaved?(record)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
@ -184,10 +185,10 @@ final class NikonCatalogLiveCaptureService {
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
|
||||
guard let albumID else {
|
||||
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
@ -196,15 +197,14 @@ final class NikonCatalogLiveCaptureService {
|
||||
filename: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
return try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,8 +15,8 @@ final class SonyRemoteCaptureService {
|
||||
|
||||
private var albumID: Int?
|
||||
private let photoRepository: PhotoRepositoryProtocol
|
||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((String) -> Void)?
|
||||
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||
|
||||
private var pollTimer: Timer?
|
||||
private var propertyCheckTask: Task<Void, Never>?
|
||||
@ -298,7 +298,7 @@ final class SonyRemoteCaptureService {
|
||||
filename: downloaded.filename,
|
||||
albumID: albumID
|
||||
)
|
||||
try photoRepository.addPhoto(
|
||||
let record = try photoRepository.addPhoto(
|
||||
albumID: albumID,
|
||||
localPath: fileURL.path,
|
||||
createdAt: Date()
|
||||
@ -307,7 +307,7 @@ final class SonyRemoteCaptureService {
|
||||
.sony,
|
||||
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)"
|
||||
)
|
||||
onShotSaved?(downloaded.filename)
|
||||
onShotSaved?(record)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")
|
||||
|
||||
@ -15,14 +15,14 @@ protocol ConnectionManagerDelegate: AnyObject {
|
||||
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
|
||||
/// 边拍边传:单张照片已下载并写入目标相册。
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int)
|
||||
/// 边拍边传:单张照片已下载并写入目标相册本地缓存。
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord)
|
||||
}
|
||||
|
||||
extension ConnectionManagerDelegate {
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {}
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {}
|
||||
}
|
||||
|
||||
/// 有线相机连接管理抽象,便于页面 ViewModel 测试连接与目录就绪状态。
|
||||
@ -553,14 +553,14 @@ private extension ConnectionManager {
|
||||
nikonLiveCapture = nil
|
||||
}
|
||||
|
||||
private func makeLiveShotSavedHandler() -> (String) -> Void {
|
||||
{ [weak self] filename in
|
||||
private func makeLiveShotSavedHandler() -> (TravelAlbumOTGPhotoRecord) -> Void {
|
||||
{ [weak self] record in
|
||||
guard let self, let albumID = self.liveTransferAlbumID else { return }
|
||||
guard record.albumId == albumID else { return }
|
||||
self.notify {
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didSaveLiveShot: filename,
|
||||
albumID: albumID
|
||||
didSaveLiveShot: record
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,44 @@ enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 传输模式,决定新照片传入 App 后是否立即上传 OSS。
|
||||
enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||
case liveUpload
|
||||
case postTransfer
|
||||
|
||||
/// 展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .liveUpload: return "边拍边传"
|
||||
case .postTransfer: return "拍后传输"
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否在新照片导入 App 后自动上传。
|
||||
var shouldAutoUploadNewImports: Bool {
|
||||
self == .liveUpload
|
||||
}
|
||||
|
||||
/// 根据展示标题解析传输模式。
|
||||
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
||||
allCases.first { $0.title == title }
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 指定上传弹窗选项。
|
||||
enum TravelAlbumOTGSpecifyUploadOption: String, CaseIterable, Sendable {
|
||||
case allPending
|
||||
case todayCaptured
|
||||
|
||||
/// 展示标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .allPending: return "上传所有未上传的照片"
|
||||
case .todayCaptured: return "上传所有今日拍摄的照片"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumOTGPhotoItem {
|
||||
/// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。
|
||||
func capturedDate() -> Date? {
|
||||
|
||||
@ -89,7 +89,8 @@ protocol PhotoRepositoryProtocol {
|
||||
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord]
|
||||
|
||||
/// 添加一张已下载到本地的照片。
|
||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws
|
||||
@discardableResult
|
||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord
|
||||
|
||||
/// 删除指定照片及本地文件。
|
||||
func deletePhoto(photoID: String, albumID: Int) throws
|
||||
@ -110,12 +111,15 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
||||
}
|
||||
|
||||
/// 添加一张已下载到本地的照片。
|
||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws {
|
||||
@discardableResult
|
||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord {
|
||||
let fileURL = URL(fileURLWithPath: localPath)
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: localPath)
|
||||
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||||
let userId = storage.context.userId
|
||||
guard !userId.isEmpty else { return }
|
||||
guard !userId.isEmpty else {
|
||||
throw CameraError.connectionFailed("用户信息缺失,无法保存照片")
|
||||
}
|
||||
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
||||
@ -128,6 +132,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
||||
userId: userId
|
||||
)
|
||||
storage.upsert(record, albumId: albumID)
|
||||
return record
|
||||
}
|
||||
|
||||
/// 删除指定照片及本地文件。
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user