修复 OTG 列表删除照片后边拍边传仍会带回已删项的问题。

删除时同步清理管道任务并维护 excludedAssetIDs,避免 notify 将无本地文件的旧任务重新展示为仅文件名的待上传项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 15:58:25 +08:00
parent 90d135b5ac
commit 19d681192c
4 changed files with 109 additions and 0 deletions

View File

@ -27,6 +27,8 @@ final class CameraTransferPipeline {
private let deferredNotifyInterval: TimeInterval = 0.15 private let deferredNotifyInterval: TimeInterval = 0.15
private let maxConcurrentUploads = 3 private let maxConcurrentUploads = 3
private var isUIAttached = false private var isUIAttached = false
/// asset ID
private(set) var excludedAssetIDs: Set<String> = []
/// ///
init(cameraService: any CameraServiceProtocol) { init(cameraService: any CameraServiceProtocol) {
@ -224,7 +226,24 @@ final class CameraTransferPipeline {
tasks.first { $0.assetID == assetID } tasks.first { $0.assetID == assetID }
} }
/// asset ID sync/notify
func setExcludedAssetIDs(_ ids: Set<String>) {
excludedAssetIDs = ids
}
/// asset
func removeAsset(assetID: String) {
guard !assetID.isEmpty else { return }
excludedAssetIDs.insert(assetID)
tasks.removeAll { $0.assetID == assetID }
autoUploadEligibleAssetIDs.remove(assetID)
activeUploadAssetIDs.remove(assetID)
inFlightAssetIDs.remove(assetID)
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
}
private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async { private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async {
if excludedAssetIDs.contains(asset.id) { return }
if skipIfExists, if skipIfExists,
let existing = tasks.first(where: { $0.assetID == asset.id }), let existing = tasks.first(where: { $0.assetID == asset.id }),
existing.status == .uploaded || existing.status == .downloaded { existing.status == .uploaded || existing.status == .downloaded {

View File

@ -449,6 +449,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:] private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:] private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
private var lastProgressPersistDates: [String: Date] = [:] private var lastProgressPersistDates: [String: Date] = [:]
private var deletedPhotoIDs: Set<String> = []
private var wasConnected = false private var wasConnected = false
private var disconnectAlertTask: Task<Void, Never>? private var disconnectAlertTask: Task<Void, Never>?
private var isTearingDownCamera = false private var isTearingDownCamera = false
@ -799,9 +800,12 @@ final class WiredCameraTransferViewModel: ObservableObject {
/// ///
func deletePhoto(id: String) { func deletePhoto(id: String) {
photoStore?.remove(albumID: context.albumId, photoID: id) photoStore?.remove(albumID: context.albumId, photoID: id)
deletedPhotoIDs.insert(id)
pipeline.removeAsset(assetID: id)
persistedRecordsByID.removeValue(forKey: id) persistedRecordsByID.removeValue(forKey: id)
lastAppliedPipelineSignatures.removeValue(forKey: id) lastAppliedPipelineSignatures.removeValue(forKey: id)
lastProgressPersistDates.removeValue(forKey: id) lastProgressPersistDates.removeValue(forKey: id)
sessionNewPhotoIDs.remove(id)
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true) setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
} }
@ -984,6 +988,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
private func loadPersistedPhotos() { private func loadPersistedPhotos() {
guard context.albumId > 0, let photoStore else { return } guard context.albumId > 0, let photoStore else { return }
deletedPhotoIDs = photoStore.loadDeletedIDs(albumID: context.albumId)
pipeline.setExcludedAssetIDs(deletedPhotoIDs)
let records = photoStore.load(albumID: context.albumId) let records = photoStore.load(albumID: context.albumId)
persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true) setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true)
@ -1001,6 +1007,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
) )
for task in tasks { for task in tasks {
guard !deletedPhotoIDs.contains(task.assetID) else { continue }
guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue } guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue }
let signature = PipelineTaskSignature(task: task) let signature = PipelineTaskSignature(task: task)

View File

@ -202,6 +202,37 @@ final class CameraTransferPipelineTests: XCTestCase {
XCTAssertEqual(camera.shutdownCount, 0) XCTAssertEqual(camera.shutdownCount, 0)
} }
/// notify asset
func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws {
let camera = MockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("removed_asset")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope)
attachPipeline(pipeline)
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG")
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG")
try Data(repeating: 0x01, count: 8).write(to: oldURL)
try Data(repeating: 0x02, count: 8).write(to: newURL)
defer {
try? FileManager.default.removeItem(at: oldURL)
try? FileManager.default.removeItem(at: newURL)
}
camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
pipeline.removeAsset(assetID: "ptp_old")
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new"))
XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old"))
}
private func attachPipeline( private func attachPipeline(
_ pipeline: CameraTransferPipeline, _ pipeline: CameraTransferPipeline,
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in } onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in }

View File

@ -46,6 +46,58 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
} }
///
func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 7701,
albumName: "删除回归测试",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG")
try Data(repeating: 0x11, count: 16).write(to: oldURL)
defer { try? FileManager.default.removeItem(at: oldURL) }
camera.downloadURL = oldURL
camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
viewModel.deletePhoto(id: "ptp_delete_me")
XCTAssertTrue(viewModel.photos.isEmpty)
viewModel.selectTransferMode(
WiredCameraTransferViewModel.modeLiveCapture,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG")
try Data(repeating: 0x22, count: 16).write(to: newURL)
defer { try? FileManager.default.removeItem(at: newURL) }
camera.downloadURL = newURL
camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new")
XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" })
}
/// start detach UI shutdown /// start detach UI shutdown
func testStartCancellationDetachesWithoutShutdown() async { func testStartCancellationDetachesWithoutShutdown() async {
let camera = DisconnectTrackingWiredCameraService() let camera = DisconnectTrackingWiredCameraService()