Gallery NAS was only showing Kisani-manifest-tracked files (2 of 7432). Now fetches the full NAS directory listing on load and adds Phase 3 items for files not in the manifest (uploaded by other tools) — all 7432 files appear in the gallery with thumbnails loaded lazily on scroll. Settings network section: "No Wi-Fi" → "Wi-Fi" when connected but SSID is nil (no location permission). Added cellular status row below Wi-Fi row. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
334 lines
14 KiB
Swift
334 lines
14 KiB
Swift
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 {
|
|
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<String>(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<String>()
|
|
|
|
// 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 {}
|
|
}
|
|
}
|