将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。

恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 11:22:07 +08:00
parent d7fec35715
commit be9c844bd3
50 changed files with 5721 additions and 20 deletions

View File

@ -434,6 +434,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
@Published var showDeviceStorageAlert = false
@Published var showCameraDisconnectedAlert = false
@Published var showHelpDoc = false
@Published var showHistoryImport = false
@Published var showDisconnectCamera = false
@Published private(set) var isContentCatalogReady = false
@Published var isRefreshingCameraFiles = false
@Published var isSearchingCamera = false
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
@ -442,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
let context: WiredTransferContext
private let pipeline = WiredCameraTransferPipeline()
private lazy var otgCoordinator = OTGLiveTransferCoordinator(pipeline: pipeline)
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var downloadScope: CameraDownloadStorage.Scope?
@ -502,7 +506,21 @@ final class WiredCameraTransferViewModel: ObservableObject {
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
refreshDeviceStorageInfo()
loadPersistedPhotos()
connectionState = .disconnected
bindOTGCoordinator()
otgCoordinator.start(
albumId: context.albumId,
accountKey: accountCachePrefix,
autoUploadProvider: { [weak self] in
self?.transferModeOption == Self.modeLiveCapture
},
existingObjectIDsProvider: { [weak self] in
await self?.existingCameraObjectIDs() ?? []
}
)
connectionState = otgCoordinator.connectionState
isContentCatalogReady = otgCoordinator.isContentCatalogReady
wasConnected = connectionState.isConnected
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
@ -510,6 +528,37 @@ final class WiredCameraTransferViewModel: ObservableObject {
detachFromUI()
}
private func bindOTGCoordinator() {
otgCoordinator.onConnectionStateChange = { [weak self] state in
self?.handleConnectionStateChange(state)
}
otgCoordinator.onContentCatalogReady = { [weak self] in
self?.isContentCatalogReady = true
}
otgCoordinator.onLiveShotSaved = { [weak self] (_: SavedOTGPhoto) in
self?.refreshDeviceStorageInfo()
}
otgCoordinator.onError = { [weak self] message in
self?.errorMessage = message
}
otgCoordinator.onSuggestReplug = { [weak self] in
self?.errorMessage = "长时间未找到相机,请尝试重新插拔连接线"
}
}
private func existingCameraObjectIDs() async -> Set<String> {
var ids = Set<String>()
for record in persistedRecordsByID.values {
if let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
let objectID = ICCameraItemScanner.cameraObjectID(localPath: localURL.path) {
ids.insert(objectID)
} else {
ids.insert(record.id)
}
}
return ids
}
/// detach UI
func stop() async {
detachFromUI()
@ -525,6 +574,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
disconnectAlertTask?.cancel()
showCameraDisconnectedAlert = false
pipeline.detachFromUI()
otgCoordinator.detach()
}
///
@ -582,20 +632,98 @@ final class WiredCameraTransferViewModel: ObservableObject {
errorMessage = nil
}
/// OTG
/// catalog
func refreshCameraFiles() async {
guard !isRefreshingCameraFiles else { return }
isRefreshingCameraFiles = true
defer { isRefreshingCameraFiles = false }
if connectionState.isConnected, isContentCatalogReady, let driver = otgCoordinator.currentDriver {
do {
let objects = try await driver.listObjects()
for object in objects {
guard !deletedPhotoIDs.contains(object.id) else { continue }
guard !persistedRecordsByID.keys.contains(object.id) else { continue }
pipeline.markDownloading(assetID: object.id, filename: object.filename)
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("camera-sync", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
if let scope = downloadScope,
let sink = makeTransientPhotoSink(scope: scope) {
let saved = try await sink.saveDownloadedFile(
at: downloadedURL,
filename: object.filename,
capturedAt: object.capturedAt,
cameraObjectID: object.id
)
pipeline.ingestDownloadedAsset(
assetID: saved.assetID,
filename: saved.filename,
localPath: saved.relativeLocalPath,
capturedAt: saved.capturedAt,
autoUpload: false
)
}
}
} catch {
errorMessage = error.localizedDescription
}
return
}
loadPersistedPhotos()
}
/// OTG
/// USB
func restartCameraSearch() async {
guard !isSearchingCamera else { return }
guard !connectionState.isConnected, !isSearchingCamera else { return }
isSearchingCamera = true
defer { isSearchingCamera = false }
otgCoordinator.restartSearch()
connectionState = otgCoordinator.connectionState
}
///
func disconnectCamera() {
otgCoordinator.disconnect()
connectionState = .disconnected
isContentCatalogReady = false
wasConnected = false
}
///
func onHistoryImportClick() {
guard connectionState.isConnected, isContentCatalogReady else {
errorMessage = "请等待相机连接完成后再导入"
return
}
showHistoryImport = true
}
/// ViewModel
func makeHistoryImportViewModel(accountKey: String) -> CameraHistoryImportViewModel? {
otgCoordinator.makeHistoryImportViewModel(
albumId: context.albumId,
accountKey: accountKey,
onImported: { [weak self] in
self?.loadPersistedPhotos()
}
)
}
private func makeTransientPhotoSink(scope: CameraDownloadStorage.Scope) -> TravelAlbumOTGPhotoSink? {
TravelAlbumOTGPhotoSink(
albumId: context.albumId,
accountKey: scope.accountKey,
pipeline: pipeline,
existingObjectIDsProvider: { [weak self] in
await self?.existingCameraObjectIDs() ?? []
},
autoUploadProvider: { [weak self] in
self?.transferModeOption == Self.modeLiveCapture
}
)
}
/// / /