Files
Kisani/Features/Browse/GalleryViewModel.swift

357 lines
15 KiB
Swift
Raw Normal View History

import Foundation
import Photos
import UIKit
import Combine
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
private let videoExts: Set<String> = ["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<Void, Never>?
private var cancellables = Set<AnyCancellable>()
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 {
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
manifestEntries = m.entries
} else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty {
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
manifestEntries = cached.entries
} else if let conn = connection {
manifestEntries = await Self.fetchManifestDirect(conn: conn)
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign Cached indexes: - NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json) Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download. - LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json) Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver. O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls). Fast path reconcile: When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration entirely and returns in microseconds. Falls through to full NAS path only when stale. AutoBackupCoordinator.onActive(): Changed force: true → force: false so the 30-second debounce prevents expensive rescans on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot. BackupQueueItem + queue tracking: BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress, speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView always has data to show even after restart. NotificationService: Centralised local notification sender replacing the inline UNMutableNotificationContent blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category identifiers so same-type notifications replace each other. SyncView redesign: - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text - No live NAS directory listing (was expensive NAS connection on every tab open) - Shows engine.queueItems grouped into Active / Failed / Completed sections - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed - Idle state shows archive count from statusService snapshot GalleryViewModel: NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest is nil (offline launch). Gallery shows NAS items even when NAS is unreachable. AppDelegate: Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking). Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
} 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)
// allNASFilenames tracks every filename already represented so Phase 3 skips duplicates
var allNASFilenames = Set<String>(minimumCapacity: manifest.count + nasDirectory.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 }
allNASFilenames.insert(key)
}
// Build NAS directory lookup for filename-based sync matching in Phase 1
var nasByName = [String: NASItem](minimumCapacity: nasDirectory.count)
for item in nasDirectory {
let lower = item.name.lowercased()
if nasByName[lower] == nil { nasByName[lower] = item }
}
var result: [GalleryItem] = []
result.reserveCapacity(phone.count + manifest.count + nasDirectory.count / 4)
var coveredFilenames = Set<String>()
// Phase 1: Phone assets
// Priority: manifest match (Kisani upload) NAS directory match (other tool) phone-only
for (localID, filename, date, duration, isVideo) in phone {
let key = filename.lowercased()
if let entry = byID[localID] ?? byName[key] {
// Kisani manifest match green dot (.synced)
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 if let nasItem = nasByName[key] {
// Filename match in NAS directory (uploaded by other tool) green dot (.synced)
allNASFilenames.insert(key) // mark covered so Phase 3 skips it
result.append(GalleryItem(
id: "synced_\(localID)",
filename: filename,
creationDate: date,
duration: duration,
fileSize: nasItem.size > 0 ? nasItem.size : nil,
mediaType: isVideo ? .video : .photo,
source: .synced(localIdentifier: localID, nasPath: nasItem.path)
))
} else {
// Not on NAS orange dot (.phone)
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 already shown (uploaded by other tools, not on phone)
for item in nasDirectory {
let lower = item.name.lowercased()
guard !allNASFilenames.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 }
allNASFilenames.insert(lower)
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 {}
}
}