import Foundation import Photos import UIKit import Combine private let imageExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"] private let videoExts: Set = ["mp4","mov","m4v","avi","mkv"] @MainActor final class GalleryViewModel: ObservableObject { enum Tab: String, CaseIterable { case all = "All", phone = "Phone", nas = "NAS" } @Published var tab: Tab = .all @Published var searchText = "" @Published private(set) var sections: [GallerySection] = [] @Published private(set) var isLoading = false @Published private(set) var nasError: String? private(set) var allItems: [GalleryItem] = [] private var loadTask: Task? private var cancellables = Set() private weak var boundStatusService: BackupStatusService? private var boundConnection: NASConnection? init() { $tab .dropFirst() .receive(on: RunLoop.main) .sink { [weak self] _ in self?.recomputeSections() } .store(in: &cancellables) $searchText .dropFirst() .debounce(for: .milliseconds(150), scheduler: RunLoop.main) .sink { [weak self] _ in self?.recomputeSections() } .store(in: &cancellables) } /// Call once to wire up reactive reloading when the manifest arrives. func bind(to statusService: BackupStatusService, connection: NASConnection?) { boundStatusService = statusService boundConnection = connection // When BackupStatusService finishes reconciling, reload if our items are stale. statusService.$lastManifest .dropFirst() .compactMap { $0 } .filter { !$0.entries.isEmpty } .receive(on: RunLoop.main) .sink { [weak self] manifest in guard let self else { return } // Only reload if the manifest has MORE items than what we're showing. let currentNASCount = self.allItems.filter { if case .nas = $0.source { return true } if case .synced = $0.source { return true } return false }.count if manifest.entries.count > currentNASCount { Task { await self.load(connection: connection, statusService: statusService) } } } .store(in: &cancellables) } func load(connection: NASConnection?, statusService: BackupStatusService) async { loadTask?.cancel() isLoading = true nasError = nil // Resolution order for manifest entries: // 1. statusService.lastManifest — in-memory, freshest (set after each reconcile) // 2. NASManifestCache disk cache — survives restarts without NAS connection // 3. Direct NAS download — used when both caches are cold (first launch after install) var manifestEntries: [ManifestEntry] if let m = statusService.lastManifest, !m.entries.isEmpty { manifestEntries = m.entries } else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty { manifestEntries = cached.entries } else if let conn = connection { manifestEntries = await Self.fetchManifestDirect(conn: conn) } else { manifestEntries = [] } // Fetch the full NAS directory so all files (not just Kisani-tracked ones) appear // in the gallery. This is a single listing call, not per-file. var nasDirectory: [NASItem] = [] if let conn = connection { nasDirectory = await Self.fetchNASDirectory(conn: conn) } let phoneAssets = await Task.detached(priority: .userInitiated) { Self.fetchPhoneAssets() }.value let items = await Task.detached(priority: .userInitiated) { [nasDirectory] in Self.buildItems(phone: phoneAssets, manifest: manifestEntries, nasDirectory: nasDirectory) }.value allItems = items recomputeSections() isLoading = false } /// Lists all media files in the NAS backup directory. /// Returns an empty array (silently) if the NAS is unreachable. private nonisolated static func fetchNASDirectory(conn: NASConnection) async -> [NASItem] { do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let items = try await transfer.listDirectory(at: conn.remotePath) transfer.disconnect() return items.filter { !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename } } catch { return [] } } /// Downloads the NAS manifest directly when local caches are empty. /// Caches the result so subsequent opens are instant. private nonisolated static func fetchManifestDirect(conn: NASConnection) async -> [ManifestEntry] { do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let path = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let data = try await transfer.downloadData(at: path) transfer.disconnect() let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) if let manifest { await NASManifestCache.shared.update(manifest) return manifest.entries } } catch {} return [] } func recomputeSections() { let filtered = filteredItems() sections = buildSections(from: filtered) } // MARK: — Static off-actor workers private nonisolated static func fetchPhoneAssets() -> [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)] { let opts = PHFetchOptions() opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced] let result = PHAsset.fetchAssets(with: opts) var out: [(String, String, Date?, TimeInterval?, Bool)] = [] out.reserveCapacity(result.count) result.enumerateObjects { asset, _, _ in let resources = PHAssetResource.assetResources(for: asset) let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier.prefix(8)).jpg" let isVideo = asset.mediaType == .video out.append(( asset.localIdentifier, filename, asset.creationDate, isVideo ? asset.duration : nil, isVideo )) } return out } private nonisolated static func buildItems( phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)], manifest: [ManifestEntry], nasDirectory: [NASItem] = [] ) -> [GalleryItem] { var byID = [String: ManifestEntry](minimumCapacity: manifest.count) var byName = [String: ManifestEntry](minimumCapacity: manifest.count) var manifestFilenames = Set(minimumCapacity: manifest.count) for entry in manifest { if !entry.localIdentifier.isEmpty { byID[entry.localIdentifier] = entry } let key = entry.filename.lowercased() if byName[key] == nil { byName[key] = entry } manifestFilenames.insert(key) } var result: [GalleryItem] = [] result.reserveCapacity(phone.count + manifest.count + nasDirectory.count / 4) var coveredFilenames = Set() // Phase 1: Phone assets — matched to manifest = synced, otherwise phone-only for (localID, filename, date, duration, isVideo) in phone { let key = filename.lowercased() if let entry = byID[localID] ?? byName[key] { coveredFilenames.insert(entry.filename.lowercased()) result.append(GalleryItem( id: "synced_\(localID)", filename: filename, creationDate: date, duration: duration, fileSize: entry.fileSize > 0 ? entry.fileSize : nil, mediaType: isVideo ? .video : .photo, source: .synced(localIdentifier: localID, nasPath: entry.remotePath) )) } else { result.append(GalleryItem( id: "phone_\(localID)", filename: filename, creationDate: date, duration: duration, fileSize: nil, mediaType: isVideo ? .video : .photo, source: .phone(localIdentifier: localID) )) } } // Phase 2: Manifest-only NAS items (Kisani-tracked, no longer on this device) for entry in manifest { guard !coveredFilenames.contains(entry.filename.lowercased()) else { continue } let ext = (entry.filename as NSString).pathExtension.lowercased() let mediaType: GalleryItem.MediaType = videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown result.append(GalleryItem( id: "nas_\(entry.remotePath)", filename: entry.filename, creationDate: entry.creationDate, duration: nil, fileSize: entry.fileSize > 0 ? entry.fileSize : nil, mediaType: mediaType, source: .nas(path: entry.remotePath) )) } // Phase 3: NAS directory items not tracked by the manifest (uploaded by other tools) for item in nasDirectory { let lower = item.name.lowercased() guard !manifestFilenames.contains(lower) else { continue } let ext = (item.name as NSString).pathExtension.lowercased() let mediaType: GalleryItem.MediaType = videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown guard mediaType != .unknown else { continue } result.append(GalleryItem( id: "nas_dir_\(item.path)", filename: item.name, creationDate: item.modifiedDate, duration: nil, fileSize: item.size > 0 ? item.size : nil, mediaType: mediaType, source: .nas(path: item.path) )) } result.sort { ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) } return result } // MARK: — Filtering + grouping private func filteredItems() -> [GalleryItem] { var base: [GalleryItem] switch tab { case .all: base = allItems case .phone: base = allItems.filter { if case .nas = $0.source { return false }; return true } case .nas: base = allItems.filter { if case .phone = $0.source { return false }; return true } } let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() if !q.isEmpty { base = base.filter { $0.filename.lowercased().contains(q) } } return base } private func buildSections(from items: [GalleryItem]) -> [GallerySection] { let cal = Calendar.current let currentYear = cal.component(.year, from: Date()) var sections: [GallerySection] = [] var indexMap: [String: Int] = [:] for item in items { let date = item.creationDate ?? .distantPast let sectionID: String let title: String if item.creationDate == nil { sectionID = "unknown" title = "Unknown Date" } else { let comps = cal.dateComponents([.year, .month, .day], from: date) sectionID = String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) let year = comps.year ?? currentYear if year == currentYear { title = date.formatted(.dateTime.month(.wide).day()) } else { title = date.formatted(.dateTime.month(.wide).day().year()) } } if let idx = indexMap[sectionID] { sections[idx].items.append(item) } else { indexMap[sectionID] = sections.count sections.append(GallerySection(id: sectionID, date: date, title: title, items: [item])) } } return sections } // MARK: — Actions func storeLocally(item: GalleryItem, connection: NASConnection?) async { guard case .nas(let path) = item.source, let conn = connection else { return } do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let data = try await transfer.downloadData(at: path) transfer.disconnect() let ext = (item.filename as NSString).pathExtension.lowercased() let isVideo = videoExts.contains(ext) try await PHPhotoLibrary.shared().performChanges { if isVideo { let tmpURL = FileManager.default.temporaryDirectory .appendingPathComponent(item.filename) try? data.write(to: tmpURL) PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tmpURL) } else if let img = UIImage(data: data) { PHAssetChangeRequest.creationRequestForAsset(from: img) } } } catch {} } }