From fae13eba9fd8a2a3376586aa5e2459316ac0c55e Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Tue, 19 May 2026 00:47:28 +0300 Subject: [PATCH] Gallery: show all NAS files; Settings: fix WiFi label + cellular row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Sentry --- Features/Browse/GalleryViewModel.swift | 59 +++++++++++++++++++++++--- Features/Settings/SettingsView.swift | 31 +++++++++++++- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/Features/Browse/GalleryViewModel.swift b/Features/Browse/GalleryViewModel.swift index 96a9aed..6efc368 100644 --- a/Features/Browse/GalleryViewModel.swift +++ b/Features/Browse/GalleryViewModel.swift @@ -75,19 +75,24 @@ final class GalleryViewModel: ObservableObject { } else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty { manifestEntries = cached.entries } else if let conn = connection { - // Both caches are cold — download the manifest directly so the gallery - // isn't blank while BackupStatusService reconcile is still pending. 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) { - Self.buildItems(phone: phoneAssets, manifest: manifestEntries) + let items = await Task.detached(priority: .userInitiated) { [nasDirectory] in + Self.buildItems(phone: phoneAssets, manifest: manifestEntries, nasDirectory: nasDirectory) }.value allItems = items @@ -95,6 +100,23 @@ final class GalleryViewModel: ObservableObject { 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] { @@ -148,20 +170,24 @@ final class GalleryViewModel: ObservableObject { private nonisolated static func buildItems( phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)], - manifest: [ManifestEntry] + 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 / 4) + 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] { @@ -188,7 +214,7 @@ final class GalleryViewModel: ObservableObject { } } - // NAS-only: in manifest but not matched to a phone asset + // 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() @@ -205,6 +231,25 @@ final class GalleryViewModel: ObservableObject { )) } + // 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 } diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 255d771..0b9fe15 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -213,7 +213,7 @@ struct SettingsView: View { .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { - Text(lanMonitor.currentSSID ?? (lanMonitor.isOnCellular ? "Cellular" : "No Wi-Fi")) + Text(lanMonitor.currentSSID ?? (lanMonitor.isOnCellular ? "Cellular" : (lanMonitor.isOnNetwork ? "Wi-Fi" : "No Wi-Fi"))) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(lanMonitor.isOnNetwork ? "Connected" : "Not connected") @@ -238,6 +238,35 @@ struct SettingsView: View { hairline + // Cellular row + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkSecondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text("Cellular") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + Text(lanMonitor.isOnCellular ? "Active" : "Not in use") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + HStack(spacing: 4) { + Circle() + .fill(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkQuaternary) + .frame(width: 5, height: 5) + Text(lanMonitor.isOnCellular ? "Active" : "Off") + .font(AppTheme.micro()) + .foregroundStyle(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkTertiary) + } + } + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 13) + + hairline + // NAS reachability row HStack(spacing: 12) { Image(systemName: "externaldrive.connected.to.line.below")