Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift
T
2026-07-31 16:48:28 +08:00

790 lines
27 KiB
Swift

//
// WiredCameraTransferViewModel.swift
// 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
let scenicSpotLabel: String?
private(set) var connectionState: ConnectionState = .idle {
didSet { notifyStateChanged() }
}
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
didSet {
normalizeSelectedTimeSlot()
notifyStateChanged()
}
}
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
didSet {
normalizeSelectedTimeSlot()
notifyStateChanged()
}
}
private(set) var selectedPhotoIds: Set<String> = [] {
didSet { notifyStateChanged() }
}
private(set) var selectUploadMode = false {
didSet { notifyStateChanged() }
}
private(set) var sidebarExpanded = true {
didSet { notifyStateChanged() }
}
private(set) var selectedTimeSlotId: String? {
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 transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
}
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onLiveShotSaved: ((String) -> Void)?
private let connectionManager: any WiredCameraConnectionManaging
private let storage: TravelAlbumOTGPhotoStore
private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading
private let api: any TravelAlbumServing
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
private var serverStatusSyncTask: Task<Void, Never>?
@MainActor
init(
albumId: Int,
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
self.headerPhone = headerPhone
self.scenicSpotLabel = scenicSpotLabel
self.connectionManager = connectionManager ?? ConnectionManager.shared
self.storage = storage
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader()
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
}
/// 相机状态文案。
var cameraStatusText: String {
switch connectionState {
case .idle, .disconnected:
return "未连接 USB"
case .searching:
return "正在搜索"
case .connecting:
return "正在连接"
case .connected(_, let name):
return "\(name)已连接"
case .failed:
return "连接失败"
}
}
/// 操作按钮文案。
var actionButtonText: String {
switch connectionState {
case .idle, .disconnected, .failed:
return "重新连接"
case .searching, .connecting:
return "等待连接"
case .connected:
return "刷新"
}
}
/// 当前手机型号名称。
var deviceModelName: String {
Self.currentPhoneModelName()
}
/// 当前手机可用存储空间。
var availableStorageText: String {
Self.availableStorageText()
}
/// 是否已连接相机。
var isCameraConnected: Bool {
if case .connected = connectionState {
return true
}
return false
}
/// Tab 统计。
var tabCounts: [Int] {
[
photos.count,
photos.filter { $0.status == .uploaded }.count,
photos.filter { $0.status == .failed }.count,
]
}
/// 过滤后的照片列表。
func filteredPhotos() -> [TravelAlbumOTGPhotoItem] {
switch selectedTab {
case .all:
return photos
case .uploaded:
return photos.filter { $0.status == .uploaded }
case .failed:
return photos.filter { $0.status == .failed }
}
}
/// 过滤后照片的半小时时间分段。
var photoSections: [TravelAlbumOTGPhotoSection] {
filteredPhotos().buildPhotoSections()
}
/// 过滤后照片的左侧时间导航分组。
var sidebarGroups: [TravelAlbumOTGDateGroup] {
filteredPhotos().buildSidebarGroups()
}
/// 当前可见列表中可被选择上传的照片 ID。
var selectableVisiblePhotoIds: Set<String> {
Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id))
}
/// 当前可见列表中是否已全选可上传照片。
var allVisibleSelectablePhotosSelected: Bool {
let ids = selectableVisiblePhotoIds
return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds)
}
/// 当前可见列表中已选择的可上传照片数量。
var selectedVisibleUploadCount: Int {
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
}
/// 传输模式展示文案。
var transferModeOption: String {
transferMode.title
}
/// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
}
/// 是否可打开指定上传选项。
var canOpenSpecifyUploadChooser: Bool {
!selectUploadMode
}
/// 绑定连接管理器并启动发现。
func start() {
connectionManager.configureLiveTransfer(albumID: albumId)
connectionManager.delegate = self
syncFromConnectionManager()
loadPersistedPhotos()
syncServerUploadStatuses()
connectionManager.start()
}
/// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。
func stop() {
serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil
connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer()
}
/// 主动断开相机连接。
func disconnect() {
connectionManager.disconnect()
}
/// 刷新相机文件列表或重新连接。
func refreshCameraFiles() {
switch connectionState {
case .connected:
loadPersistedPhotos()
case .idle, .disconnected, .failed:
connectionManager.start()
default:
break
}
}
/// 重新加载当前相册本地 OTG 照片。
func reloadLocalPhotos() {
loadPersistedPhotos()
}
/// 切换 Tab。
func selectTab(_ tab: TravelAlbumOTGTransferTab) {
selectedTab = tab
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
}
/// 展开或收起左侧时间快速导航。
func toggleSidebar() {
sidebarExpanded.toggle()
}
/// 选择左侧时间槽,并联动主列表滚动。
func selectTimeSlot(id: String) {
guard photoSections.contains(where: { $0.slotId == id }) else { return }
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 let mode = TravelAlbumOTGTransferMode.option(title: option),
mode != transferMode else {
return
}
transferMode = mode
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
/// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
photoFormatOption = option
applyMergedPhotos()
}
/// 批量上传按钮:首次进入选择模式,再次上传选中照片。
func onBatchUploadButtonClick() {
if !selectUploadMode {
selectUploadMode = true
selectedPhotoIds = []
return
}
if selectedPhotoIds.isEmpty {
selectUploadMode = false
return
}
uploadSelectedTransferPhotos()
}
/// 选择指定上传选项后开始上传。
func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) {
guard canOpenSpecifyUploadChooser else { return }
switch option {
case .allPending:
uploadAllNotUploadedPhotos()
case .todayCaptured:
uploadAllTodayCapturedPhotos()
}
}
/// 创建相机历史照片选择导入页 ViewModel。
func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? {
guard isContentCatalogReady else {
showMessage("相机照片目录尚未就绪,请稍候再试")
return nil
}
guard let currentDriver else {
showMessage("请先连接相机")
return nil
}
if case .connected = connectionState {
return TravelAlbumCameraImportViewModel(
albumId: albumId,
albumTitle: albumTitle,
driver: currentDriver,
storage: storage,
appStore: appStore
)
}
showMessage("请先连接相机")
return nil
}
/// 历史导入期间暂停边拍边传轮询,避免 PTP 轮询与 ImageCaptureCore 下载互相抢占。
func pauseLiveTransferForHistoricalImport() {
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.session.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,
photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else {
return
}
if selectedPhotoIds.contains(photoId) {
selectedPhotoIds.remove(photoId)
} else {
selectedPhotoIds.insert(photoId)
}
}
/// 切换当前可见照片的全选状态。
func toggleVisibleSelectablePhotos() {
guard selectUploadMode else { return }
let ids = selectableVisiblePhotoIds
guard !ids.isEmpty else { return }
if ids.isSubset(of: selectedPhotoIds) {
selectedPhotoIds.subtract(ids)
} else {
selectedPhotoIds.formUnion(ids)
}
}
/// 上传选中照片。
func uploadSelectedTransferPhotos() {
let ids = selectedPhotoIds
guard !ids.isEmpty else {
showMessage("请先选择照片")
return
}
selectUploadMode = false
selectedPhotoIds = []
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: "本地文件不存在,无法重传")
}
/// 删除单张本地照片。
func deletePhoto(photoId: String) {
storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
applyMergedPhotos()
showMessage("已删除")
}
/// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() {
storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:]
photos = []
}
private func syncFromConnectionManager() {
currentDriver = connectionManager.currentDriver
isContentCatalogReady = connectionManager.isContentCatalogReady
connectionState = connectionManager.state
}
private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
applyMergedPhotos()
}
private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel()
guard albumId > 0 else { return }
let baseline = persistedRecordsById
serverStatusSyncTask = Task { [weak self] in
guard let self else { return }
do {
let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId)
try Task.checkCancellation()
let activeIds = Set(persistedRecordsById.values.compactMap { record in
record.status == .transferring || record.status == .uploading ? record.id : nil
})
let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile(
records: Array(persistedRecordsById.values),
baselineRecordsById: baseline,
activePhotoIds: activeIds,
serverClientPhotoIds: response.clientPhotoIds,
now: Int64(Date().timeIntervalSince1970 * 1000)
)
let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) })
guard reconciledById != persistedRecordsById else { return }
persistedRecordsById = reconciledById
storage.save(reconciled, albumId: albumId)
applyMergedPhotos()
selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds)
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)")
}
}
}
private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
}
private func normalizeSelectedTimeSlot() {
let sections = filteredPhotos().buildPhotoSections()
guard let firstSlotId = sections.first?.slotId else {
selectedTimeSlotId = nil
return
}
if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) {
selectedTimeSlotId = firstSlotId
}
}
private func startUploadByIds(
_ ids: Set<String>,
emptyMessage: String,
queueIfBusy: Bool = false
) {
guard !isUploading else {
if queueIfBusy {
queuedAutoUploadPhotoIds.formUnion(ids)
} else {
showMessage("正在上传,请稍候")
}
return
}
let targets = resolveUploadTargets(ids)
guard !targets.isEmpty else {
showMessage(emptyMessage)
return
}
isUploading = true
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 { id in
guard let record = persistedRecordsById[id] else { return false }
return record.status == .pending || record.status == .failed
}
}
private func uploadPhoto(id: String) async {
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
let material = try await uploader.upload(
record: record,
scenicId: appStore.session.currentScenicId
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
storage.fileExists(relativePath: record.localPath, albumId: albumId) {
return storage.recordWithResolvedFilePaths(record, albumId: albumId)
}
throw TravelAlbumOTGUploadError.localFileMissing
}
private func updateRecord(
id: String,
status: TravelAlbumOTGUploadStatus,
progress: Int,
error: String?,
remoteUrl: String? = nil
) {
var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) {
record = TravelAlbumOTGPhotoRecord(
id: item.id,
sourceId: item.sourceId,
fileName: item.fileName,
localPath: item.localPath,
thumbnailPath: item.thumbnailURL?.isFileURL == true
? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId)
: "",
capturedAt: item.capturedAt,
fileSizeBytes: item.fileSizeBytes,
status: status,
progress: progress,
errorMessage: error,
albumId: albumId,
userId: appStore.session.userId,
remoteUrl: remoteUrl ?? item.remoteUrl
)
}
guard var record else { return }
record.status = status
record.progress = min(max(progress, 0), 100)
record.errorMessage = error
if let remoteUrl {
record.remoteUrl = remoteUrl
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
private func notifyStateChanged() {
if Thread.isMainThread {
onStateChange?()
} else {
DispatchQueue.main.async { [weak self] in self?.onStateChange?() }
}
}
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)
} else {
DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) }
}
}
}
extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
connectionState = state
}
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
currentDriver = driver
}
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
currentDriver = driver
isContentCatalogReady = true
notifyStateChanged()
}
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
showMessage(error.localizedDescription)
}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
showMessage("长时间未找到相机,请尝试重新插拔连接线")
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {
guard record.albumId == albumId else { return }
handleNewlyImportedPhotoIds([record.id])
onLiveShotSaved?(record.fileName)
}
}