Fix OTG album local path storage

This commit is contained in:
2026-07-08 10:25:35 +08:00
parent e9f593643f
commit 773fbd1180
8 changed files with 206 additions and 76 deletions

View File

@ -134,17 +134,17 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord {
///
func toPhotoItem() -> TravelAlbumOTGPhotoItem {
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true }
return FileManager.default.fileExists(atPath: path)
return storage.fileExists(relativePath: path, albumId: albumId)
} ?? ""
let previewURL: URL?
if previewPath.hasPrefix("http") {
previewURL = URL(string: previewPath)
} else if !previewPath.isEmpty {
previewURL = URL(fileURLWithPath: previewPath)
previewURL = storage.absoluteURL(for: previewPath, albumId: albumId)
} else {
previewURL = nil
}

View File

@ -71,15 +71,6 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
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 {
guard status == .transferring || status == .uploading else { return self }
@ -129,7 +120,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
let record = TravelAlbumOTGPhotoRecord(
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
fileName: fileURL.lastPathComponent,
localPath: localPath,
localPath: storage.relativePath(for: fileURL, albumId: albumID),
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt),
fileSizeBytes: size,
status: .pending,
@ -192,11 +183,10 @@ final class TravelAlbumOTGPhotoStore {
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
return []
}
let deletedIds = loadDeletedIds(albumId: albumId)
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() }
.filter(\.isDisplayable)
.filter { isDisplayable($0, albumId: albumId) }
.sorted { $0.capturedAt > $1.capturedAt }
}
@ -205,6 +195,7 @@ final class TravelAlbumOTGPhotoStore {
guard albumId > 0, !context.userId.isEmpty else { return }
let normalized = records
.filter { $0.albumId == albumId && $0.userId == context.userId }
.map { recordByPersistingRelativePaths($0, albumId: albumId) }
.sorted { $0.capturedAt > $1.capturedAt }
do {
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
@ -227,7 +218,6 @@ final class TravelAlbumOTGPhotoStore {
guard albumId > 0, !photoId.isEmpty else { return }
let records = load(albumId: albumId)
records.filter { $0.id == photoId }.forEach(deleteFiles)
saveDeletedIds(loadDeletedIds(albumId: albumId).union([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))
}
/// 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 {
let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true)
@ -292,6 +273,58 @@ final class TravelAlbumOTGPhotoStore {
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
static func photoId(for fileName: String, createdAt: Date) -> String {
let timestamp = Int64(createdAt.timeIntervalSince1970)
@ -339,27 +372,46 @@ final class TravelAlbumOTGPhotoStore {
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) {
[record.localPath, record.thumbnailPath].forEach { path in
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 {
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true

View File

@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable {
case loading
case loaded(sections: [TravelAlbumCameraImportSection])
case importing(current: Int, total: Int)
case finished(importedCount: Int)
case finished(importedCount: Int, failedCount: Int)
case failed(message: String)
}
@ -193,7 +193,7 @@ final class TravelAlbumCameraImportViewModel {
id: object.id,
sourceId: object.id,
fileName: downloadedURL.lastPathComponent,
localPath: downloadedURL.path,
localPath: storage.relativePath(for: downloadedURL, albumId: albumId),
thumbnailPath: "",
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
fileSizeBytes: object.fileSize,
@ -212,7 +212,7 @@ final class TravelAlbumCameraImportViewModel {
}
selectedPhotoIds.subtract(existingPhotoIds)
state = .finished(importedCount: importedCount)
state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count)
}
}

View File

@ -47,7 +47,6 @@ final class WiredCameraTransferViewModel {
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
private var deletedPhotoIds: Set<String> = []
private var isUploading = false
@MainActor
@ -235,6 +234,11 @@ final class WiredCameraTransferViewModel {
return nil
}
/// PTP ImageCaptureCore
func pauseLiveTransferForHistoricalImport() {
connectionManager.suspendLiveTransfer()
}
///
func toggleTransferPhotoSelection(photoId: String) {
guard selectUploadMode,
@ -268,7 +272,6 @@ final class WiredCameraTransferViewModel {
///
func deletePhoto(photoId: String) {
storage.remove(albumId: albumId, photoId: photoId)
deletedPhotoIds.insert(photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
applyMergedPhotos()
@ -289,7 +292,6 @@ final class WiredCameraTransferViewModel {
}
private func loadPersistedPhotos() {
deletedPhotoIds = storage.loadDeletedIds(albumId: albumId)
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
applyMergedPhotos()
@ -297,8 +299,7 @@ final class WiredCameraTransferViewModel {
private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values
.filter { !deletedPhotoIds.contains($0.id) }
.map { $0.toPhotoItem() }
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
}
@ -347,8 +348,8 @@ final class WiredCameraTransferViewModel {
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
FileManager.default.fileExists(atPath: record.localPath) {
return record
storage.fileExists(relativePath: record.localPath, albumId: albumId) {
return storage.recordWithResolvedFilePaths(record, albumId: albumId)
}
throw TravelAlbumOTGUploadError.localFileMissing
}
@ -367,7 +368,9 @@ final class WiredCameraTransferViewModel {
sourceId: item.sourceId,
fileName: item.fileName,
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,
fileSizeBytes: item.fileSizeBytes,
status: status,

View File

@ -16,7 +16,8 @@ final class TravelAlbumCameraImportViewController: UIViewController {
}
private let viewModel: TravelAlbumCameraImportViewModel
var onImportFinished: ((Int) -> Void)?
var onImportFinished: ((Int, Int) -> Void)?
var onImportCancelled: (() -> Void)?
private var collectionView: UICollectionView!
private var sections: [TravelAlbumCameraImportSection] = []
@ -75,6 +76,11 @@ final class TravelAlbumCameraImportViewController: UIViewController {
collectionView.collectionViewLayout.invalidateLayout()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self
}
private func setupUI() {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = Layout.spacing
@ -150,7 +156,6 @@ final class TravelAlbumCameraImportViewController: UIViewController {
}
viewModel.onSelectionUpdated = { [weak self] count in
self?.updateImportButton(count: count)
self?.collectionView.reloadData()
}
viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in
self?.updateHelpVisibility(show)
@ -160,6 +165,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
private func render(_ state: TravelAlbumCameraImportState) {
switch state {
case .idle, .loading:
isModalInPresentation = false
collectionView.isHidden = true
messageLabel.isHidden = true
helpButton.isHidden = true
@ -168,6 +174,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
activityIndicator.startAnimating()
case .loaded(let loadedSections):
isModalInPresentation = false
activityIndicator.stopAnimating()
collectionView.isHidden = false
messageLabel.isHidden = true
@ -177,6 +184,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
collectionView.reloadData()
case .importing(let current, let total):
isModalInPresentation = true
collectionView.isHidden = true
messageLabel.isHidden = false
messageLabel.text = "正在导入 \(current)/\(total)..."
@ -185,13 +193,15 @@ final class TravelAlbumCameraImportViewController: UIViewController {
navigationItem.leftBarButtonItem?.isEnabled = false
activityIndicator.startAnimating()
case .finished(let count):
case .finished(let importedCount, let failedCount):
isModalInPresentation = false
activityIndicator.stopAnimating()
dismiss(animated: true) { [weak self] in
self?.onImportFinished?(count)
self?.onImportFinished?(importedCount, failedCount)
}
case .failed(let message):
isModalInPresentation = false
activityIndicator.stopAnimating()
collectionView.isHidden = true
messageLabel.isHidden = false
@ -229,7 +239,52 @@ final class TravelAlbumCameraImportViewController: UIViewController {
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() {
onImportCancelled?()
dismiss(animated: true)
}
@ -282,21 +337,7 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDataSource {
withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier,
for: indexPath
) as! TravelAlbumCameraImportSectionHeaderView
let section = sections[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))
}
applyHeader(header, at: indexPath.section)
return header
}
}
@ -306,8 +347,8 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay
let photo = sections[indexPath.section].photos[indexPath.item]
guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return }
viewModel.togglePhoto(id: photo.id)
collectionView.reloadItems(at: [indexPath])
collectionView.reloadSections(IndexSet(integer: indexPath.section))
reloadItemsWithoutAnimation([indexPath])
refreshVisibleHeader(at: indexPath.section)
}
func collectionView(
@ -322,6 +363,12 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay
}
}
extension TravelAlbumCameraImportViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
onImportCancelled?()
}
}
/// Cell
private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell"

View File

@ -421,10 +421,20 @@ final class WiredCameraTransferViewController: BaseViewController {
@objc private func historyImportTapped() {
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
viewModel.pauseLiveTransferForHistoricalImport()
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
importController.onImportFinished = { [weak self] count in
self?.viewModel.reloadLocalPhotos()
self?.showToast("已导入 \(count) 张照片")
importController.onImportFinished = { [weak self] importedCount, failedCount in
guard let self else { return }
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)
navigationController.modalPresentationStyle = .pageSheet