Fix OTG album local path storage
This commit is contained in:
@ -134,17 +134,17 @@ enum TravelAlbumOTGPhotoFormatMatcher {
|
|||||||
|
|
||||||
extension TravelAlbumOTGPhotoRecord {
|
extension TravelAlbumOTGPhotoRecord {
|
||||||
/// 转为页面展示项。
|
/// 转为页面展示项。
|
||||||
func toPhotoItem() -> TravelAlbumOTGPhotoItem {
|
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
|
||||||
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
|
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
|
||||||
guard !path.isEmpty else { return false }
|
guard !path.isEmpty else { return false }
|
||||||
if path.hasPrefix("http") { return true }
|
if path.hasPrefix("http") { return true }
|
||||||
return FileManager.default.fileExists(atPath: path)
|
return storage.fileExists(relativePath: path, albumId: albumId)
|
||||||
} ?? ""
|
} ?? ""
|
||||||
let previewURL: URL?
|
let previewURL: URL?
|
||||||
if previewPath.hasPrefix("http") {
|
if previewPath.hasPrefix("http") {
|
||||||
previewURL = URL(string: previewPath)
|
previewURL = URL(string: previewPath)
|
||||||
} else if !previewPath.isEmpty {
|
} else if !previewPath.isEmpty {
|
||||||
previewURL = URL(fileURLWithPath: previewPath)
|
previewURL = storage.absoluteURL(for: previewPath, albumId: albumId)
|
||||||
} else {
|
} else {
|
||||||
previewURL = nil
|
previewURL = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,15 +71,6 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 是否仍可在页面展示。
|
|
||||||
var isDisplayable: Bool {
|
|
||||||
if status == .uploaded { return true }
|
|
||||||
if !remoteUrl.isEmpty { return true }
|
|
||||||
if !localPath.isEmpty, FileManager.default.fileExists(atPath: localPath) { return true }
|
|
||||||
if !thumbnailPath.isEmpty, FileManager.default.fileExists(atPath: thumbnailPath) { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||||
guard status == .transferring || status == .uploading else { return self }
|
guard status == .transferring || status == .uploading else { return self }
|
||||||
@ -129,7 +120,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
|||||||
let record = TravelAlbumOTGPhotoRecord(
|
let record = TravelAlbumOTGPhotoRecord(
|
||||||
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
||||||
fileName: fileURL.lastPathComponent,
|
fileName: fileURL.lastPathComponent,
|
||||||
localPath: localPath,
|
localPath: storage.relativePath(for: fileURL, albumId: albumID),
|
||||||
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt),
|
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt),
|
||||||
fileSizeBytes: size,
|
fileSizeBytes: size,
|
||||||
status: .pending,
|
status: .pending,
|
||||||
@ -192,11 +183,10 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
let deletedIds = loadDeletedIds(albumId: albumId)
|
|
||||||
return decoded
|
return decoded
|
||||||
.filter { $0.albumId == albumId && $0.userId == context.userId && !deletedIds.contains($0.id) }
|
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||||||
.map { $0.normalizedAfterInterruptedTransfer() }
|
.map { $0.normalizedAfterInterruptedTransfer() }
|
||||||
.filter(\.isDisplayable)
|
.filter { isDisplayable($0, albumId: albumId) }
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,6 +195,7 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
guard albumId > 0, !context.userId.isEmpty else { return }
|
guard albumId > 0, !context.userId.isEmpty else { return }
|
||||||
let normalized = records
|
let normalized = records
|
||||||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||||||
|
.map { recordByPersistingRelativePaths($0, albumId: albumId) }
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
do {
|
do {
|
||||||
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
||||||
@ -227,7 +218,6 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
guard albumId > 0, !photoId.isEmpty else { return }
|
guard albumId > 0, !photoId.isEmpty else { return }
|
||||||
let records = load(albumId: albumId)
|
let records = load(albumId: albumId)
|
||||||
records.filter { $0.id == photoId }.forEach(deleteFiles)
|
records.filter { $0.id == photoId }.forEach(deleteFiles)
|
||||||
saveDeletedIds(loadDeletedIds(albumId: albumId).union([photoId]), albumId: albumId)
|
|
||||||
save(records.filter { $0.id != photoId }, albumId: albumId)
|
save(records.filter { $0.id != photoId }, albumId: albumId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,15 +228,6 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
try? fileManager.removeItem(at: previewRoot(albumId: albumId))
|
try? fileManager.removeItem(at: previewRoot(albumId: albumId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回已删除照片 ID。
|
|
||||||
func loadDeletedIds(albumId: Int) -> Set<String> {
|
|
||||||
guard let data = try? Data(contentsOf: deletedIndexURL(albumId: albumId)),
|
|
||||||
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Set(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 原片目录。
|
/// 原片目录。
|
||||||
func originalsDirectory(albumId: Int) throws -> URL {
|
func originalsDirectory(albumId: Int) throws -> URL {
|
||||||
let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true)
|
let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true)
|
||||||
@ -292,6 +273,58 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回写入索引用的相对路径。
|
||||||
|
func relativePath(for url: URL, albumId: Int) -> String {
|
||||||
|
let path = url.standardizedFileURL.path
|
||||||
|
let originalRoot = albumRoot(albumId: albumId)
|
||||||
|
.appendingPathComponent("originals", isDirectory: true)
|
||||||
|
.standardizedFileURL
|
||||||
|
.path
|
||||||
|
if let relative = pathComponent(path, relativeTo: originalRoot) {
|
||||||
|
return "originals/\(relative)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewPath = previewRoot(albumId: albumId).standardizedFileURL.path
|
||||||
|
if let relative = pathComponent(path, relativeTo: previewPath) {
|
||||||
|
return "previews/\(relative)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把索引中的相对路径解析到当前沙盒绝对 URL。
|
||||||
|
func absoluteURL(for relativePath: String, albumId: Int) -> URL? {
|
||||||
|
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil }
|
||||||
|
if trimmed.hasPrefix("originals/") {
|
||||||
|
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("previews/") {
|
||||||
|
return previewRoot(albumId: albumId)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent(trimmed)
|
||||||
|
}
|
||||||
|
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断索引相对路径对应的文件是否存在。
|
||||||
|
func fileExists(relativePath: String, albumId: Int) -> Bool {
|
||||||
|
guard let url = absoluteURL(for: relativePath, albumId: albumId) else { return false }
|
||||||
|
return fileManager.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回可直接用于文件读取的记录副本。
|
||||||
|
func recordWithResolvedFilePaths(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> TravelAlbumOTGPhotoRecord {
|
||||||
|
var resolved = record
|
||||||
|
if let localURL = absoluteURL(for: record.localPath, albumId: albumId) {
|
||||||
|
resolved.localPath = localURL.path
|
||||||
|
}
|
||||||
|
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
|
||||||
|
resolved.thumbnailPath = thumbnailURL.path
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
/// 生成稳定照片 ID。
|
/// 生成稳定照片 ID。
|
||||||
static func photoId(for fileName: String, createdAt: Date) -> String {
|
static func photoId(for fileName: String, createdAt: Date) -> String {
|
||||||
let timestamp = Int64(createdAt.timeIntervalSince1970)
|
let timestamp = Int64(createdAt.timeIntervalSince1970)
|
||||||
@ -339,27 +372,46 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
albumRoot(albumId: albumId).appendingPathComponent("records.json")
|
albumRoot(albumId: albumId).appendingPathComponent("records.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deletedIndexURL(albumId: Int) -> URL {
|
|
||||||
albumRoot(albumId: albumId).appendingPathComponent("deleted.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveDeletedIds(_ ids: Set<String>, albumId: Int) {
|
|
||||||
do {
|
|
||||||
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
|
||||||
let data = try JSONEncoder().encode(Array(ids))
|
|
||||||
try data.write(to: deletedIndexURL(albumId: albumId), options: .atomic)
|
|
||||||
} catch {
|
|
||||||
OTGLog.warning(.connection, "save deleted OTG ids failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
|
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
|
||||||
[record.localPath, record.thumbnailPath].forEach { path in
|
[record.localPath, record.thumbnailPath].forEach { path in
|
||||||
guard !path.isEmpty else { return }
|
guard !path.isEmpty else { return }
|
||||||
try? fileManager.removeItem(atPath: path)
|
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
|
||||||
|
try? fileManager.removeItem(at: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isDisplayable(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> Bool {
|
||||||
|
if record.status == .uploaded { return true }
|
||||||
|
if !record.remoteUrl.isEmpty { return true }
|
||||||
|
if fileExists(relativePath: record.localPath, albumId: albumId) { return true }
|
||||||
|
if fileExists(relativePath: record.thumbnailPath, albumId: albumId) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordByPersistingRelativePaths(
|
||||||
|
_ record: TravelAlbumOTGPhotoRecord,
|
||||||
|
albumId: Int
|
||||||
|
) -> TravelAlbumOTGPhotoRecord {
|
||||||
|
var normalized = record
|
||||||
|
if !record.localPath.isEmpty {
|
||||||
|
normalized.localPath = record.localPath.hasPrefix("/")
|
||||||
|
? relativePath(for: URL(fileURLWithPath: record.localPath), albumId: albumId)
|
||||||
|
: record.localPath
|
||||||
|
}
|
||||||
|
if !record.thumbnailPath.isEmpty {
|
||||||
|
normalized.thumbnailPath = record.thumbnailPath.hasPrefix("/")
|
||||||
|
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
|
||||||
|
: record.thumbnailPath
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pathComponent(_ path: String, relativeTo root: String) -> String? {
|
||||||
|
let prefix = root.hasSuffix("/") ? root : "\(root)/"
|
||||||
|
guard path.hasPrefix(prefix) else { return nil }
|
||||||
|
return String(path.dropFirst(prefix.count))
|
||||||
|
}
|
||||||
|
|
||||||
private func excludeFromBackup(_ url: URL) throws {
|
private func excludeFromBackup(_ url: URL) throws {
|
||||||
var resourceValues = URLResourceValues()
|
var resourceValues = URLResourceValues()
|
||||||
resourceValues.isExcludedFromBackup = true
|
resourceValues.isExcludedFromBackup = true
|
||||||
|
|||||||
@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable {
|
|||||||
case loading
|
case loading
|
||||||
case loaded(sections: [TravelAlbumCameraImportSection])
|
case loaded(sections: [TravelAlbumCameraImportSection])
|
||||||
case importing(current: Int, total: Int)
|
case importing(current: Int, total: Int)
|
||||||
case finished(importedCount: Int)
|
case finished(importedCount: Int, failedCount: Int)
|
||||||
case failed(message: String)
|
case failed(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ final class TravelAlbumCameraImportViewModel {
|
|||||||
id: object.id,
|
id: object.id,
|
||||||
sourceId: object.id,
|
sourceId: object.id,
|
||||||
fileName: downloadedURL.lastPathComponent,
|
fileName: downloadedURL.lastPathComponent,
|
||||||
localPath: downloadedURL.path,
|
localPath: storage.relativePath(for: downloadedURL, albumId: albumId),
|
||||||
thumbnailPath: "",
|
thumbnailPath: "",
|
||||||
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
|
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
|
||||||
fileSizeBytes: object.fileSize,
|
fileSizeBytes: object.fileSize,
|
||||||
@ -212,7 +212,7 @@ final class TravelAlbumCameraImportViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedPhotoIds.subtract(existingPhotoIds)
|
selectedPhotoIds.subtract(existingPhotoIds)
|
||||||
state = .finished(importedCount: importedCount)
|
state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,6 @@ final class WiredCameraTransferViewModel {
|
|||||||
|
|
||||||
private var currentDriver: CameraDriver?
|
private var currentDriver: CameraDriver?
|
||||||
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
||||||
private var deletedPhotoIds: Set<String> = []
|
|
||||||
private var isUploading = false
|
private var isUploading = false
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -235,6 +234,11 @@ final class WiredCameraTransferViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 历史导入期间暂停边拍边传轮询,避免 PTP 轮询与 ImageCaptureCore 下载互相抢占。
|
||||||
|
func pauseLiveTransferForHistoricalImport() {
|
||||||
|
connectionManager.suspendLiveTransfer()
|
||||||
|
}
|
||||||
|
|
||||||
/// 选择或取消选择照片。
|
/// 选择或取消选择照片。
|
||||||
func toggleTransferPhotoSelection(photoId: String) {
|
func toggleTransferPhotoSelection(photoId: String) {
|
||||||
guard selectUploadMode,
|
guard selectUploadMode,
|
||||||
@ -268,7 +272,6 @@ final class WiredCameraTransferViewModel {
|
|||||||
/// 删除单张本地照片。
|
/// 删除单张本地照片。
|
||||||
func deletePhoto(photoId: String) {
|
func deletePhoto(photoId: String) {
|
||||||
storage.remove(albumId: albumId, photoId: photoId)
|
storage.remove(albumId: albumId, photoId: photoId)
|
||||||
deletedPhotoIds.insert(photoId)
|
|
||||||
persistedRecordsById.removeValue(forKey: photoId)
|
persistedRecordsById.removeValue(forKey: photoId)
|
||||||
selectedPhotoIds.remove(photoId)
|
selectedPhotoIds.remove(photoId)
|
||||||
applyMergedPhotos()
|
applyMergedPhotos()
|
||||||
@ -289,7 +292,6 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPersistedPhotos() {
|
private func loadPersistedPhotos() {
|
||||||
deletedPhotoIds = storage.loadDeletedIds(albumId: albumId)
|
|
||||||
let records = storage.load(albumId: albumId)
|
let records = storage.load(albumId: albumId)
|
||||||
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
||||||
applyMergedPhotos()
|
applyMergedPhotos()
|
||||||
@ -297,8 +299,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
|
|
||||||
private func applyMergedPhotos() {
|
private func applyMergedPhotos() {
|
||||||
let persistedItems = persistedRecordsById.values
|
let persistedItems = persistedRecordsById.values
|
||||||
.filter { !deletedPhotoIds.contains($0.id) }
|
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
||||||
.map { $0.toPhotoItem() }
|
|
||||||
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
|
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,8 +348,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
|
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
|
||||||
if let record = persistedRecordsById[id],
|
if let record = persistedRecordsById[id],
|
||||||
!record.localPath.isEmpty,
|
!record.localPath.isEmpty,
|
||||||
FileManager.default.fileExists(atPath: record.localPath) {
|
storage.fileExists(relativePath: record.localPath, albumId: albumId) {
|
||||||
return record
|
return storage.recordWithResolvedFilePaths(record, albumId: albumId)
|
||||||
}
|
}
|
||||||
throw TravelAlbumOTGUploadError.localFileMissing
|
throw TravelAlbumOTGUploadError.localFileMissing
|
||||||
}
|
}
|
||||||
@ -367,7 +368,9 @@ final class WiredCameraTransferViewModel {
|
|||||||
sourceId: item.sourceId,
|
sourceId: item.sourceId,
|
||||||
fileName: item.fileName,
|
fileName: item.fileName,
|
||||||
localPath: item.localPath,
|
localPath: item.localPath,
|
||||||
thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "",
|
thumbnailPath: item.thumbnailURL?.isFileURL == true
|
||||||
|
? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId)
|
||||||
|
: "",
|
||||||
capturedAt: item.capturedAt,
|
capturedAt: item.capturedAt,
|
||||||
fileSizeBytes: item.fileSizeBytes,
|
fileSizeBytes: item.fileSizeBytes,
|
||||||
status: status,
|
status: status,
|
||||||
|
|||||||
@ -16,7 +16,8 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let viewModel: TravelAlbumCameraImportViewModel
|
private let viewModel: TravelAlbumCameraImportViewModel
|
||||||
var onImportFinished: ((Int) -> Void)?
|
var onImportFinished: ((Int, Int) -> Void)?
|
||||||
|
var onImportCancelled: (() -> Void)?
|
||||||
|
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var sections: [TravelAlbumCameraImportSection] = []
|
private var sections: [TravelAlbumCameraImportSection] = []
|
||||||
@ -75,6 +76,11 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
collectionView.collectionViewLayout.invalidateLayout()
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
navigationController?.presentationController?.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
let layout = UICollectionViewFlowLayout()
|
let layout = UICollectionViewFlowLayout()
|
||||||
layout.minimumLineSpacing = Layout.spacing
|
layout.minimumLineSpacing = Layout.spacing
|
||||||
@ -150,7 +156,6 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
viewModel.onSelectionUpdated = { [weak self] count in
|
viewModel.onSelectionUpdated = { [weak self] count in
|
||||||
self?.updateImportButton(count: count)
|
self?.updateImportButton(count: count)
|
||||||
self?.collectionView.reloadData()
|
|
||||||
}
|
}
|
||||||
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
|
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
|
||||||
self?.updateHelpVisibility(show)
|
self?.updateHelpVisibility(show)
|
||||||
@ -160,6 +165,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
private func render(_ state: TravelAlbumCameraImportState) {
|
private func render(_ state: TravelAlbumCameraImportState) {
|
||||||
switch state {
|
switch state {
|
||||||
case .idle, .loading:
|
case .idle, .loading:
|
||||||
|
isModalInPresentation = false
|
||||||
collectionView.isHidden = true
|
collectionView.isHidden = true
|
||||||
messageLabel.isHidden = true
|
messageLabel.isHidden = true
|
||||||
helpButton.isHidden = true
|
helpButton.isHidden = true
|
||||||
@ -168,6 +174,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
case .loaded(let loadedSections):
|
case .loaded(let loadedSections):
|
||||||
|
isModalInPresentation = false
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
collectionView.isHidden = false
|
collectionView.isHidden = false
|
||||||
messageLabel.isHidden = true
|
messageLabel.isHidden = true
|
||||||
@ -177,6 +184,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
collectionView.reloadData()
|
collectionView.reloadData()
|
||||||
|
|
||||||
case .importing(let current, let total):
|
case .importing(let current, let total):
|
||||||
|
isModalInPresentation = true
|
||||||
collectionView.isHidden = true
|
collectionView.isHidden = true
|
||||||
messageLabel.isHidden = false
|
messageLabel.isHidden = false
|
||||||
messageLabel.text = "正在导入 \(current)/\(total)..."
|
messageLabel.text = "正在导入 \(current)/\(total)..."
|
||||||
@ -185,13 +193,15 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
navigationItem.leftBarButtonItem?.isEnabled = false
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
case .finished(let count):
|
case .finished(let importedCount, let failedCount):
|
||||||
|
isModalInPresentation = false
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
dismiss(animated: true) { [weak self] in
|
dismiss(animated: true) { [weak self] in
|
||||||
self?.onImportFinished?(count)
|
self?.onImportFinished?(importedCount, failedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
|
isModalInPresentation = false
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
collectionView.isHidden = true
|
collectionView.isHidden = true
|
||||||
messageLabel.isHidden = false
|
messageLabel.isHidden = false
|
||||||
@ -229,7 +239,52 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
return CGSize(width: width, height: width + 28)
|
return CGSize(width: width, height: width + 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyHeader(_ header: TravelAlbumCameraImportSectionHeaderView, at sectionIndex: Int) {
|
||||||
|
let section = sections[sectionIndex]
|
||||||
|
header.apply(
|
||||||
|
title: section.title,
|
||||||
|
count: section.photos.count,
|
||||||
|
importableCount: viewModel.importablePhotoCount(in: section),
|
||||||
|
importedCount: viewModel.importedPhotoCount(in: section),
|
||||||
|
hasImportablePhotos: viewModel.hasImportablePhotos(in: section),
|
||||||
|
fullySelected: viewModel.isSectionFullySelected(section),
|
||||||
|
partiallySelected: viewModel.isSectionPartiallySelected(section)
|
||||||
|
)
|
||||||
|
header.onToggle = { [weak self] in
|
||||||
|
self?.toggleSection(at: sectionIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshVisibleHeader(at sectionIndex: Int) {
|
||||||
|
let indexPath = IndexPath(item: 0, section: sectionIndex)
|
||||||
|
guard let header = collectionView.supplementaryView(
|
||||||
|
forElementKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
at: indexPath
|
||||||
|
) as? TravelAlbumCameraImportSectionHeaderView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyHeader(header, at: sectionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadItemsWithoutAnimation(_ indexPaths: [IndexPath]) {
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
collectionView.reloadItems(at: indexPaths)
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleSection(at sectionIndex: Int) {
|
||||||
|
guard sections.indices.contains(sectionIndex) else { return }
|
||||||
|
viewModel.toggleSection(sections[sectionIndex])
|
||||||
|
let indexPaths = sections[sectionIndex].photos.indices.map {
|
||||||
|
IndexPath(item: $0, section: sectionIndex)
|
||||||
|
}
|
||||||
|
reloadItemsWithoutAnimation(indexPaths)
|
||||||
|
refreshVisibleHeader(at: sectionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@objc private func cancelTapped() {
|
||||||
|
onImportCancelled?()
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,21 +337,7 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDataSource {
|
|||||||
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier,
|
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! TravelAlbumCameraImportSectionHeaderView
|
) as! TravelAlbumCameraImportSectionHeaderView
|
||||||
let section = sections[indexPath.section]
|
applyHeader(header, at: indexPath.section)
|
||||||
header.apply(
|
|
||||||
title: section.title,
|
|
||||||
count: section.photos.count,
|
|
||||||
importableCount: viewModel.importablePhotoCount(in: section),
|
|
||||||
importedCount: viewModel.importedPhotoCount(in: section),
|
|
||||||
hasImportablePhotos: viewModel.hasImportablePhotos(in: section),
|
|
||||||
fullySelected: viewModel.isSectionFullySelected(section),
|
|
||||||
partiallySelected: viewModel.isSectionPartiallySelected(section)
|
|
||||||
)
|
|
||||||
header.onToggle = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.viewModel.toggleSection(section)
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
||||||
}
|
|
||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,8 +347,8 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay
|
|||||||
let photo = sections[indexPath.section].photos[indexPath.item]
|
let photo = sections[indexPath.section].photos[indexPath.item]
|
||||||
guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return }
|
guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return }
|
||||||
viewModel.togglePhoto(id: photo.id)
|
viewModel.togglePhoto(id: photo.id)
|
||||||
collectionView.reloadItems(at: [indexPath])
|
reloadItemsWithoutAnimation([indexPath])
|
||||||
collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
refreshVisibleHeader(at: indexPath.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(
|
func collectionView(
|
||||||
@ -322,6 +363,12 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumCameraImportViewController: UIAdaptivePresentationControllerDelegate {
|
||||||
|
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||||
|
onImportCancelled?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 相机历史照片导入 Cell。
|
/// 相机历史照片导入 Cell。
|
||||||
private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell {
|
private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell"
|
static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell"
|
||||||
|
|||||||
@ -421,10 +421,20 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func historyImportTapped() {
|
@objc private func historyImportTapped() {
|
||||||
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
||||||
|
viewModel.pauseLiveTransferForHistoricalImport()
|
||||||
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
||||||
importController.onImportFinished = { [weak self] count in
|
importController.onImportFinished = { [weak self] importedCount, failedCount in
|
||||||
self?.viewModel.reloadLocalPhotos()
|
guard let self else { return }
|
||||||
self?.showToast("已导入 \(count) 张照片")
|
self.viewModel.reloadLocalPhotos()
|
||||||
|
self.viewModel.start()
|
||||||
|
if failedCount > 0 {
|
||||||
|
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
|
||||||
|
} else {
|
||||||
|
self.showToast("已导入 \(importedCount) 张照片")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
importController.onImportCancelled = { [weak self] in
|
||||||
|
self?.viewModel.start()
|
||||||
}
|
}
|
||||||
let navigationController = UINavigationController(rootViewController: importController)
|
let navigationController = UINavigationController(rootViewController: importController)
|
||||||
navigationController.modalPresentationStyle = .pageSheet
|
navigationController.modalPresentationStyle = .pageSheet
|
||||||
|
|||||||
@ -56,6 +56,8 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(loaded.first?.status, .pending)
|
XCTAssertEqual(loaded.first?.status, .pending)
|
||||||
XCTAssertEqual(loaded.first?.progress, 0)
|
XCTAssertEqual(loaded.first?.progress, 0)
|
||||||
|
XCTAssertEqual(loaded.first?.localPath, "originals/A.JPG")
|
||||||
|
XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRemoveAndClearAlbumDeleteFiles() throws {
|
func testRemoveAndClearAlbumDeleteFiles() throws {
|
||||||
|
|||||||
@ -208,6 +208,16 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testHistoricalImportPausesLiveTransferPolling() {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
|
||||||
|
let viewModel = makeWiredViewModel(context: context, manager: manager)
|
||||||
|
|
||||||
|
viewModel.pauseLiveTransferForHistoricalImport()
|
||||||
|
|
||||||
|
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册相机历史导入 ViewModel 测试。
|
/// 旅拍相册相机历史导入 ViewModel 测试。
|
||||||
@ -353,6 +363,12 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
|
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
|
||||||
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
|
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
|
||||||
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
|
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
|
||||||
|
if case .finished(let importedCount, let failedCount) = viewModel.state {
|
||||||
|
XCTAssertEqual(importedCount, 1)
|
||||||
|
XCTAssertEqual(failedCount, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected finished state")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSonyEmptyCatalogShowsMTPHelp() async {
|
func testSonyEmptyCatalogShowsMTPHelp() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user