diff --git a/Forji/Forji/App/AppDelegate.swift b/Forji/Forji/App/AppDelegate.swift new file mode 100644 index 0000000..f17b9c1 --- /dev/null +++ b/Forji/Forji/App/AppDelegate.swift @@ -0,0 +1,77 @@ +import ForgejoKit +import UIKit +import UserNotifications + +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil, + ) -> Bool { + NotificationPollingService.shared.registerBackgroundTask() + BackgroundDownloadManager.shared.reconnectIfNeeded() + + let center = UNUserNotificationCenter.current() + center.delegate = self + + let markReadAction = UNNotificationAction( + identifier: "MARK_READ", + title: "Mark as Read", + options: [], + ) + let category = UNNotificationCategory( + identifier: "FORGEJO_NOTIFICATION", + actions: [markReadAction], + intentIdentifiers: [], + ) + center.setNotificationCategories([category]) + + return true + } + + func application( + _: UIApplication, + handleEventsForBackgroundURLSession _: String, + completionHandler: @escaping () -> Void, + ) { + BackgroundDownloadManager.shared.setCompletionHandler(completionHandler) + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + ) async -> UNNotificationPresentationOptions { + [.banner, .sound, .badge] + } + + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + ) async { + let userInfo = response.notification.request.content.userInfo + guard let serverURL = userInfo["serverURL"] as? String, + let username = userInfo["username"] as? String + else { return } + + if response.actionIdentifier == "MARK_READ" { + await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username) + } else { + await MainActor.run { + NavigationState.shared.navigateToNotifications(serverURL: serverURL, username: username) + } + } + } + + private func markNotificationAsRead(userInfo: [AnyHashable: Any], serverURL: String, username: String) async { + guard let notificationID = userInfo["notificationID"] as? Int, + let token = try? await KeychainManager.shared.getToken(for: serverURL, username: username) + else { return } + + let client = ForgejoClient(serverURL: serverURL, username: username, token: token) + let service = NotificationService(client: client) + try? await service.markAsRead(id: notificationID) + } +} diff --git a/Forji/Forji/App/ContentView.swift b/Forji/Forji/App/ContentView.swift index 0ebd07b..991a2e5 100644 --- a/Forji/Forji/App/ContentView.swift +++ b/Forji/Forji/App/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @Query private var allInstances: [ForgejoInstance] @State private var hasAttemptedAutoLogin = false @Environment(\.modelContext) private var modelContext + @Environment(NavigationState.self) private var navigationState // Dev auto-login credentials, set via launch arguments for development and UI integration tests #if DEBUG @@ -52,6 +53,18 @@ struct ContentView: View { } } .preferredColorScheme(appearance.colorScheme) + .onChange(of: navigationState.pendingNavigation) { _, pending in + guard let pending else { return } + guard authService.isAuthenticated, + let currentInstance = authService.currentInstance + else { return } + + let currentURL = ForgejoClient.normalizeServerURL(currentInstance.serverURL) + let pendingURL = ForgejoClient.normalizeServerURL(pending.serverURL) + if currentURL != pendingURL || currentInstance.username != pending.username { + Task { await switchInstance(to: pending) } + } + } } private func attemptAutoLogin() async { @@ -126,11 +139,22 @@ struct ContentView: View { } } #endif + + private func switchInstance(to pending: PendingNavigation) async { + let pendingURL = ForgejoClient.normalizeServerURL(pending.serverURL) + let pendingUsername = pending.username + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.serverURL == pendingURL && $0.username == pendingUsername + }) + guard let instance = try? modelContext.fetch(descriptor).first else { return } + try? await authService.restoreSession(instance: instance) + } } #if DEBUG #Preview { ContentView() .modelContainer(for: ForgejoInstance.self, inMemory: true) + .environment(NavigationState.shared) } #endif diff --git a/Forji/Forji/App/ForjiApp.swift b/Forji/Forji/App/ForjiApp.swift index 10820b0..c526913 100644 --- a/Forji/Forji/App/ForjiApp.swift +++ b/Forji/Forji/App/ForjiApp.swift @@ -3,6 +3,10 @@ import SwiftUI @main struct ForjiApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @Environment(\.scenePhase) private var scenePhase + @AppStorage("notificationsEnabled") private var notificationsEnabled = false + var sharedModelContainer: ModelContainer = { let schema = Schema([ ForgejoInstance.self, @@ -19,7 +23,27 @@ struct ForjiApp: App { var body: some Scene { WindowGroup { ContentView() + .environment(NavigationState.shared) } .modelContainer(sharedModelContainer) + .onChange(of: scenePhase) { _, newPhase in + guard notificationsEnabled else { return } + switch newPhase { + case .active: + Task { + await NotificationPollingService.shared.startForegroundPolling( + modelContainer: sharedModelContainer, + ) + } + case .background: + Task { + await NotificationPollingService.shared.stopForegroundPolling() + BackgroundDownloadManager.shared.startDownloads() + NotificationPollingService.shared.scheduleBackgroundPoll() + } + default: + break + } + } } } diff --git a/Forji/Forji/Helpers/CacheInvalidation.swift b/Forji/Forji/Helpers/CacheInvalidation.swift index edf0566..2f6257e 100644 --- a/Forji/Forji/Helpers/CacheInvalidation.swift +++ b/Forji/Forji/Helpers/CacheInvalidation.swift @@ -3,4 +3,5 @@ import Foundation extension Notification.Name { static let issuesDidChange = Notification.Name("issuesDidChange") static let pullRequestsDidChange = Notification.Name("pullRequestsDidChange") + static let notificationsDidChange = Notification.Name("notificationsDidChange") } diff --git a/Forji/Forji/Models/NavigationState.swift b/Forji/Forji/Models/NavigationState.swift new file mode 100644 index 0000000..4e8576a --- /dev/null +++ b/Forji/Forji/Models/NavigationState.swift @@ -0,0 +1,27 @@ +import Foundation + +struct PendingNavigation: Equatable { + let serverURL: String + let username: String + let targetTab: Int +} + +@Observable +@MainActor +final class NavigationState { + static let shared = NavigationState() + + var pendingNavigation: PendingNavigation? + var notificationsRefreshTrigger = 0 + + private init() {} + + func navigateToNotifications(serverURL: String, username: String) { + pendingNavigation = PendingNavigation(serverURL: serverURL, username: username, targetTab: 3) + notificationsRefreshTrigger += 1 + } + + func consume() { + pendingNavigation = nil + } +} diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift index 4549f0b..fc0b86f 100644 --- a/Forji/Forji/Services/AuthenticationService.swift +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -108,6 +108,8 @@ class AuthenticationService { func restoreSession(instance: ForgejoInstance) async throws { let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + // Migrate keychain items to AfterFirstUnlock accessibility for background access + await KeychainManager.shared.migrateAccessibility(for: normalizedURL, username: instance.username) try await restoreWithCredentials( serverURL: normalizedURL, username: instance.username, @@ -128,7 +130,7 @@ class AuthenticationService { } private func restoreWithCredentials( - serverURL: String, username: String, allowSelfSigned: Bool, useTokenAuth: Bool + serverURL: String, username: String, allowSelfSigned: Bool, useTokenAuth: Bool, ) async throws { // Try restoring from stored API token first (avoids 2FA prompt) if let token = try? await KeychainManager.shared.getToken(for: serverURL, username: username) { diff --git a/Forji/Forji/Services/BackgroundDownloadManager.swift b/Forji/Forji/Services/BackgroundDownloadManager.swift new file mode 100644 index 0000000..ee56588 --- /dev/null +++ b/Forji/Forji/Services/BackgroundDownloadManager.swift @@ -0,0 +1,271 @@ +import ForgejoKit +import Foundation +import SwiftData +import UIKit +import UserNotifications + +final class BackgroundDownloadManager: NSObject, Sendable { + static let shared = BackgroundDownloadManager() + + private static let sessionIdentifier = "de.hausotte.Forji.bgDownload" + + private let seenStore = SeenNotificationStore() + private let queue = DispatchQueue(label: "de.hausotte.Forji.bgDownloadManager") + + private nonisolated(unsafe) var backgroundSession: URLSession! + private nonisolated(unsafe) var completionHandler: (() -> Void)? + private nonisolated(unsafe) var pendingDownloads = 0 + private nonisolated(unsafe) var totalUnread = 0 + + override private init() { + super.init() + backgroundSession = createSession() + } + + private func createSession() -> URLSession { + let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.timeoutIntervalForResource = 60 + return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.from(queue)) + } + + // MARK: - Public API + + func reconnectIfNeeded() { + // Accessing backgroundSession triggers reconnection to any in-flight downloads + _ = backgroundSession + } + + func setCompletionHandler(_ handler: @escaping () -> Void) { + queue.sync { completionHandler = handler } + } + + func startDownloads() { + Task { @MainActor in + let instances = self.fetchInstances() + guard !instances.isEmpty else { return } + + self.queue.sync { + self.pendingDownloads = 0 + self.totalUnread = 0 + } + + for instance in instances { + guard let request = await self.buildRequest(for: instance) else { continue } + self.queue.sync { self.pendingDownloads += 1 } + self.backgroundSession.downloadTask(with: request).resume() + } + } + } + + // MARK: - Instance Fetching + + @MainActor + private func fetchInstances() -> [InstanceMetadata] { + do { + let schema = Schema([ForgejoInstance.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let container = try ModelContainer(for: schema, configurations: [config]) + let context = ModelContext(container) + let descriptor = FetchDescriptor() + let fetched = try context.fetch(descriptor) + return fetched.map { + InstanceMetadata( + serverURL: ForgejoClient.normalizeServerURL($0.serverURL), + username: $0.username, + name: $0.name, + ) + } + } catch { + return [] + } + } + + private func buildRequest(for instance: InstanceMetadata) async -> URLRequest? { + guard let token = try? await KeychainManager.shared.getToken( + for: instance.serverURL, username: instance.username, + ) else { return nil } + + var components = URLComponents(string: "\(instance.serverURL)/api/v1/notifications")! + components.queryItems = [ + URLQueryItem(name: "status-types", value: "unread"), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "50"), + ] + + guard let url = components.url else { return nil } + + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + + storeMetadata(instance, for: url) + + return request + } + + // MARK: - Metadata Storage + + private func storeMetadata(_ metadata: InstanceMetadata, for url: URL) { + let key = url.absoluteString + var map = loadMetadataMap() + map[key] = metadata + if let data = try? JSONEncoder().encode(map) { + try? data.write(to: metadataFileURL()) + } + } + + private func loadMetadata(for url: URL) -> InstanceMetadata? { + let map = loadMetadataMap() + return map[url.absoluteString] + } + + private func removeMetadata(for url: URL) { + var map = loadMetadataMap() + map.removeValue(forKey: url.absoluteString) + if let data = try? JSONEncoder().encode(map) { + try? data.write(to: metadataFileURL()) + } + } + + private func loadMetadataMap() -> [String: InstanceMetadata] { + guard let data = try? Data(contentsOf: metadataFileURL()), + let map = try? JSONDecoder().decode([String: InstanceMetadata].self, from: data) + else { return [:] } + return map + } + + private func metadataFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("forji_bg_metadata.json") + } +} + +// MARK: - URLSessionDownloadDelegate + +extension BackgroundDownloadManager: URLSessionDownloadDelegate { + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + guard let originalURL = downloadTask.originalRequest?.url, + let metadata = loadMetadata(for: originalURL) + else { return } + + defer { removeMetadata(for: originalURL) } + + guard let data = try? Data(contentsOf: location) else { return } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = forgejoDateDecodingStrategy + + guard let notifications = try? decoder.decode([NotificationThread].self, from: data) else { return } + + let currentIDs = Set(notifications.map(\.id)) + totalUnread += currentIDs.count + + if !seenStore.isSeeded(for: metadata.serverURL, username: metadata.username) { + seenStore.seed(ids: currentIDs, for: metadata.serverURL, username: metadata.username) + } else { + let seenIDs = seenStore.seenIDs(for: metadata.serverURL, username: metadata.username) + let newIDs = currentIDs.subtracting(seenIDs) + + if !newIDs.isEmpty { + let newNotifications = notifications.filter { newIDs.contains($0.id) } + let instanceName = metadata.name.isEmpty ? metadata.serverURL : metadata.name + postLocalNotifications( + newNotifications, + instanceName: instanceName, + serverURL: metadata.serverURL, + username: metadata.username, + ) + seenStore.markSeen(newIDs, for: metadata.serverURL, username: metadata.username) + } + + seenStore.prune(keeping: currentIDs, for: metadata.serverURL, username: metadata.username) + } + } + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + if error != nil { + if let url = task.originalRequest?.url { + removeMetadata(for: url) + } + } + + pendingDownloads -= 1 + if pendingDownloads <= 0 { + pendingDownloads = 0 + updateBadge(count: totalUnread) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let handler = queue.sync { () -> (() -> Void)? in + let pending = self.completionHandler + self.completionHandler = nil + return pending + } + handler?() + } + } + + // MARK: - Local Notifications + + private func postLocalNotifications( + _ notifications: [NotificationThread], + instanceName: String, + serverURL: String, + username: String, + ) { + let center = UNUserNotificationCenter.current() + + for notification in notifications { + let content = UNMutableNotificationContent() + content.title = instanceName + content.subtitle = notification.repository.fullName + content.body = notification.subject.title + content.sound = .default + content.threadIdentifier = "\(serverURL)_\(username)" + content.categoryIdentifier = "FORGEJO_NOTIFICATION" + content.userInfo = [ + "serverURL": serverURL, + "username": username, + "notificationID": notification.id, + "subjectType": notification.subject.type, + ] + + let requestID = "forji_\(serverURL)_\(notification.id)" + let request = UNNotificationRequest( + identifier: requestID, + content: content, + trigger: nil, + ) + + center.add(request) + } + } + + private func updateBadge(count: Int) { + DispatchQueue.main.async { + UNUserNotificationCenter.current().setBadgeCount(count) + } + } +} + +// MARK: - OperationQueue Helper + +private extension OperationQueue { + static func from(_ queue: DispatchQueue) -> OperationQueue { + let opQueue = OperationQueue() + opQueue.underlyingQueue = queue + opQueue.maxConcurrentOperationCount = 1 + return opQueue + } +} + +// MARK: - Instance Metadata + +struct InstanceMetadata: Codable { + let serverURL: String + let username: String + let name: String +} diff --git a/Forji/Forji/Services/KeychainManager.swift b/Forji/Forji/Services/KeychainManager.swift index 98fb1d5..bfd8bc9 100644 --- a/Forji/Forji/Services/KeychainManager.swift +++ b/Forji/Forji/Services/KeychainManager.swift @@ -40,6 +40,18 @@ actor KeychainManager { try deleteItem(forKey: key(for: server, username: username, suffix: "_token")) } + // MARK: - Migration + + func migrateAccessibility(for server: String, username: String) { + let suffixes = ["", "_token"] + for suffix in suffixes { + let account = key(for: server, username: username, suffix: suffix) + guard let value = try? getItem(forKey: account) else { continue } + try? deleteItem(forKey: account) + try? saveItem(value, forKey: account) + } + } + // MARK: - Private helpers private func saveItem(_ value: String, forKey key: String) throws { @@ -60,7 +72,7 @@ actor KeychainManager { kSecAttrService as String: Self.serviceName, kSecAttrAccount as String: key, kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] let status = SecItemAdd(addQuery as CFDictionary, nil) diff --git a/Forji/Forji/Services/MultiInstanceManager.swift b/Forji/Forji/Services/MultiInstanceManager.swift index 8538a3b..74c75bd 100644 --- a/Forji/Forji/Services/MultiInstanceManager.swift +++ b/Forji/Forji/Services/MultiInstanceManager.swift @@ -80,7 +80,7 @@ final class MultiInstanceManager { for client in clients { group.addTask { let service = NotificationService(client: client) - return (try? await service.fetchUnreadCount()) ?? 0 + return await (try? service.fetchUnreadCount()) ?? 0 } } var total = 0 @@ -111,7 +111,7 @@ final class MultiInstanceManager { } /// Sendable snapshot of ForgejoInstance data needed for session restore. -struct InstanceSnapshot: Sendable { +struct InstanceSnapshot { let serverURL: String let username: String let allowSelfSigned: Bool diff --git a/Forji/Forji/Services/NotificationPollingService.swift b/Forji/Forji/Services/NotificationPollingService.swift new file mode 100644 index 0000000..2bcf6ec --- /dev/null +++ b/Forji/Forji/Services/NotificationPollingService.swift @@ -0,0 +1,199 @@ +import BackgroundTasks +import ForgejoKit +import SwiftData +import UIKit +import UserNotifications + +actor NotificationPollingService { + static let shared = NotificationPollingService() + static let backgroundTaskIdentifier = "de.hausotte.Forji.notificationPoll" + + private let seenStore = SeenNotificationStore() + private var foregroundTask: Task? + + private init() {} + + // MARK: - Background Task Registration + + nonisolated func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.backgroundTaskIdentifier, + using: nil, + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + Task { await self.handleBackgroundTask(refreshTask) } + } + } + + nonisolated func scheduleBackgroundPoll() { + let request = BGAppRefreshTaskRequest(identifier: Self.backgroundTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + #if DEBUG + print("Failed to schedule background poll: \(error)") + #endif + } + } + + private func handleBackgroundTask(_ task: BGAppRefreshTask) async { + scheduleBackgroundPoll() + await BackgroundDownloadManager.shared.startDownloads() + task.setTaskCompleted(success: true) + } + + // MARK: - Foreground Polling + + func startForegroundPolling(modelContainer: ModelContainer) { + stopForegroundPolling() + foregroundTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await pollAllInstances( + modelContainer: modelContainer, postNotifications: false, + ) + try? await Task.sleep(for: .seconds(60)) + } + } + } + + func stopForegroundPolling() { + foregroundTask?.cancel() + foregroundTask = nil + } + + // MARK: - Core Polling + + func pollAllInstances( + modelContainer: ModelContainer, postNotifications: Bool, + ) async { + let instances: [PollingInstanceSnapshot] + do { + instances = try await MainActor.run { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + let fetched = try context.fetch(descriptor) + return fetched.map { + PollingInstanceSnapshot( + serverURL: $0.serverURL, + username: $0.username, + name: $0.name, + allowSelfSignedCertificates: $0.allowSelfSignedCertificates, + ) + } + } + } catch { + return + } + + var totalUnread = 0 + for instance in instances { + totalUnread += await pollInstance( + instance, postNotifications: postNotifications, + ) + } + await updateBadge(count: totalUnread) + } + + private func pollInstance( + _ instance: PollingInstanceSnapshot, postNotifications: Bool, + ) async -> Int { + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + guard let token = try? await KeychainManager.shared.getToken( + for: normalizedURL, username: instance.username, + ) else { + return 0 + } + + let client = ForgejoClient( + serverURL: normalizedURL, + username: instance.username, + token: token, + allowSelfSignedCertificates: instance.allowSelfSignedCertificates, + ) + let notificationService = NotificationService(client: client) + + do { + let unread = try await notificationService.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50, + ) + let currentIDs = Set(unread.map(\.id)) + + if !seenStore.isSeeded(for: normalizedURL, username: instance.username) { + seenStore.seed(ids: currentIDs, for: normalizedURL, username: instance.username) + return currentIDs.count + } + + let seenIDs = seenStore.seenIDs(for: normalizedURL, username: instance.username) + let newIDs = currentIDs.subtracting(seenIDs) + + if !newIDs.isEmpty { + if postNotifications { + let newNotifications = unread.filter { newIDs.contains($0.id) } + await postLocalNotifications( + newNotifications, + instanceName: instance.name.isEmpty ? instance.serverURL : instance.name, + serverURL: normalizedURL, + username: instance.username, + ) + } + seenStore.markSeen(newIDs, for: normalizedURL, username: instance.username) + } + + seenStore.prune(keeping: currentIDs, for: normalizedURL, username: instance.username) + return currentIDs.count + } catch { + return 0 + } + } + + // MARK: - Local Notifications + + private func postLocalNotifications( + _ notifications: [NotificationThread], + instanceName: String, + serverURL: String, + username: String, + ) async { + let center = UNUserNotificationCenter.current() + + for notification in notifications { + let content = UNMutableNotificationContent() + content.title = instanceName + content.subtitle = notification.repository.fullName + content.body = notification.subject.title + content.sound = .default + content.threadIdentifier = "\(serverURL)_\(username)" + content.categoryIdentifier = "FORGEJO_NOTIFICATION" + content.userInfo = [ + "serverURL": serverURL, + "username": username, + "notificationID": notification.id, + "subjectType": notification.subject.type, + ] + + let requestID = "forji_\(serverURL)_\(notification.id)" + let request = UNNotificationRequest( + identifier: requestID, + content: content, + trigger: nil, + ) + + try? await center.add(request) + } + } + + private func updateBadge(count: Int) async { + await MainActor.run { + UNUserNotificationCenter.current().setBadgeCount(count) + } + } +} + +private nonisolated struct PollingInstanceSnapshot { + let serverURL: String + let username: String + let name: String + let allowSelfSignedCertificates: Bool +} diff --git a/Forji/Forji/Services/SeenNotificationStore.swift b/Forji/Forji/Services/SeenNotificationStore.swift new file mode 100644 index 0000000..62d234b --- /dev/null +++ b/Forji/Forji/Services/SeenNotificationStore.swift @@ -0,0 +1,43 @@ +import Foundation + +nonisolated struct SeenNotificationStore { + private nonisolated(unsafe) let defaults: UserDefaults + + init(suiteName: String = "de.hausotte.Forji.seenNotifications") { + defaults = UserDefaults(suiteName: suiteName) ?? .standard + } + + private func key(for serverURL: String, username: String) -> String { + "seen_\(serverURL)_\(username)" + } + + private func seedKey(for serverURL: String, username: String) -> String { + "seeded_\(serverURL)_\(username)" + } + + func seenIDs(for serverURL: String, username: String) -> Set { + let ids = defaults.array(forKey: key(for: serverURL, username: username)) as? [Int] ?? [] + return Set(ids) + } + + func markSeen(_ ids: Set, for serverURL: String, username: String) { + let existing = seenIDs(for: serverURL, username: username) + let merged = existing.union(ids) + defaults.set(Array(merged), forKey: key(for: serverURL, username: username)) + } + + func seed(ids: Set, for serverURL: String, username: String) { + defaults.set(Array(ids), forKey: key(for: serverURL, username: username)) + defaults.set(true, forKey: seedKey(for: serverURL, username: username)) + } + + func isSeeded(for serverURL: String, username: String) -> Bool { + defaults.bool(forKey: seedKey(for: serverURL, username: username)) + } + + func prune(keeping currentIDs: Set, for serverURL: String, username: String) { + let existing = seenIDs(for: serverURL, username: username) + let pruned = existing.intersection(currentIDs) + defaults.set(Array(pruned), forKey: key(for: serverURL, username: username)) + } +} diff --git a/Forji/Forji/Views/HomeView.swift b/Forji/Forji/Views/HomeView.swift index 0ef6398..c998460 100644 --- a/Forji/Forji/Views/HomeView.swift +++ b/Forji/Forji/Views/HomeView.swift @@ -1,12 +1,13 @@ import ForgejoKit -import SwiftData import SwiftUI +import UserNotifications struct HomeView: View { @State private var authService: AuthenticationService? @State private var multiInstanceManager: MultiInstanceManager? @State private var unreadCount = 0 @State private var selectedTab = 0 + @Environment(NavigationState.self) private var navigationState private let notificationService: NotificationService? private var onDisconnectMerged: (() -> Void)? @@ -82,6 +83,14 @@ struct HomeView: View { .onChange(of: selectedTab) { Task { await loadUnreadCount() } } + .onChange(of: navigationState.pendingNavigation) { _, pending in + guard let pending else { return } + selectedTab = pending.targetTab + navigationState.consume() + } + .onReceive(NotificationCenter.default.publisher(for: .notificationsDidChange)) { _ in + Task { await loadUnreadCount() } + } } private func loadUnreadCount() async { @@ -90,6 +99,7 @@ struct HomeView: View { } else if let notificationService { do { unreadCount = try await notificationService.fetchUnreadCount() + try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount) } catch { // Silently ignore — badge is non-critical } @@ -97,220 +107,6 @@ struct HomeView: View { } } -// MARK: - Settings Tab - -struct SettingsTabView: View { - @AppStorage("appearance") private var appearance: AppAppearance = .system - @State private var authService: AuthenticationService - @Environment(\.modelContext) private var modelContext - - init(authService: AuthenticationService) { - self.authService = authService - } - - var body: some View { - List { - Section("Appearance") { - Picker("Theme", selection: $appearance) { - Text("System").tag(AppAppearance.system) - Text("Light").tag(AppAppearance.light) - Text("Dark").tag(AppAppearance.dark) - } - .pickerStyle(.inline) - .labelsHidden() - } - - if let user = authService.currentUser { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(user.fullName ?? user.login) - .font(.headline) - Text("@\(user.login)") - .font(.subheadline) - .foregroundStyle(.secondary) - if let email = user.email { - Text(email) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 8) - } - } - - if let instance = authService.currentInstance { - Section("Instance") { - LabeledContent("Server", value: instance.serverURL) - LabeledContent("User", value: instance.username) - } - } - - Section { - Button { - authService.disconnect() - } label: { - Label("Switch Instance", systemImage: "arrow.triangle.swap") - } - .accessibilityIdentifier("home-switch-instance-button") - - Button(role: .destructive) { - Task { await authService.logout(modelContext: modelContext) } - } label: { - Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") - } - .accessibilityIdentifier("home-logout-button") - } - - AboutSection() - } - .navigationTitle("Settings") - } -} - -// MARK: - Merged Settings Tab - -struct MergedSettingsTabView: View { - @AppStorage("appearance") private var appearance: AppAppearance = .system - @AppStorage("defaultAllInstances") private var defaultAllInstances = false - @Environment(\.modelContext) private var modelContext - @Query private var allInstances: [ForgejoInstance] - let manager: MultiInstanceManager - var onDisconnect: (() -> Void)? - - var body: some View { - List { - Section("Appearance") { - Picker("Theme", selection: $appearance) { - Text("System").tag(AppAppearance.system) - Text("Light").tag(AppAppearance.light) - Text("Dark").tag(AppAppearance.dark) - } - .pickerStyle(.inline) - .labelsHidden() - } - - Section("Connected Instances") { - ForEach(manager.connections, id: \.instance.id) { connection in - VStack(alignment: .leading, spacing: 4) { - Text(manager.displayName(for: connection.instance)) - .font(.headline) - Text(connection.instance.serverURL) - .font(.subheadline) - .foregroundStyle(.secondary) - Text("@\(connection.instance.username)") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - } - - if !manager.failedInstances.isEmpty { - Section("Failed Connections") { - ForEach(manager.failedInstances, id: \.instance.id) { failed in - VStack(alignment: .leading, spacing: 4) { - Text(manager.displayName(for: failed.instance)) - .font(.headline) - Text(failed.error) - .font(.caption) - .foregroundStyle(.red) - } - } - } - } - - Section { - Toggle("Default on Launch", isOn: $defaultAllInstances) - .onChange(of: defaultAllInstances) { _, isOn in - if isOn { - ForgejoInstance.clearDefaults(in: allInstances) - try? modelContext.save() - } - } - } footer: { - Text("Automatically connect to all instances when the app launches.") - .font(.caption) - } - - Section { - Button { - onDisconnect?() - } label: { - Label("Switch Instance", systemImage: "arrow.triangle.swap") - } - .accessibilityIdentifier("home-switch-instance-button") - } - - AboutSection() - } - .navigationTitle("Settings") - } -} - -// MARK: - About Section - -private struct AboutSection: View { - var body: some View { - Section("About") { - LabeledContent("Copyright", value: "Stefan Hausotte") - - VStack(alignment: .leading, spacing: 8) { - Text("License") - .font(.subheadline) - .foregroundStyle(.secondary) - // swiftlint:disable:next line_length - Text("Forji is free software licensed under the GNU General Public License v3.0 or later (GPLv3+). You are free to use, modify, and redistribute it under the terms of that license.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 8) { - Text("Logo") - .font(.subheadline) - .foregroundStyle(.secondary) - Text("The Forji logo is based on the Forgejo logo by Caesar Schinas, licensed under CC BY-SA 4.0.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 8) { - Text("Used Libraries") - .font(.subheadline) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 4) { - Link("ForgejoKit (MIT)", destination: URL(string: "https://codeberg.org/secana/ForgejoKit")!) - Link("Textual (MIT)", destination: URL(string: "https://github.com/gonzalezreal/textual")!) - Link( - "HighlightSwift (MIT)", - destination: URL(string: "https://github.com/appstefan/HighlightSwift")!, - ) - Link( - "mermaid (MIT)", - destination: URL(string: "https://github.com/mermaid-js/mermaid")!, - ) - Link( - "marked (MIT)", - destination: URL(string: "https://github.com/markedjs/marked")!, - ) - Link( - "DOMPurify (Apache 2.0/MPL 2.0)", - destination: URL(string: "https://github.com/cure53/DOMPurify")!, - ) - Link( - "github-markdown-css (MIT)", - destination: URL(string: "https://github.com/sindresorhus/github-markdown-css")!, - ) - } - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - } -} - #if DEBUG #Preview { HomeView(authService: .previewDefault) diff --git a/Forji/Forji/Views/MergedNotificationsOverviewView.swift b/Forji/Forji/Views/MergedNotificationsOverviewView.swift index 66f3a4f..acf7ac4 100644 --- a/Forji/Forji/Views/MergedNotificationsOverviewView.swift +++ b/Forji/Forji/Views/MergedNotificationsOverviewView.swift @@ -144,9 +144,9 @@ struct MergedNotificationsOverviewView: View { let client = source.client group.addTask { let service = NotificationService(client: client) - return (index, try await resilientFetch { + return try await (index, resilientFetch { try await service.fetchNotifications( - statusTypes: types, page: page, limit: limit + statusTypes: types, page: page, limit: limit, ) }) } diff --git a/Forji/Forji/Views/MergedRepositoryListView.swift b/Forji/Forji/Views/MergedRepositoryListView.swift index 177420d..38cafe7 100644 --- a/Forji/Forji/Views/MergedRepositoryListView.swift +++ b/Forji/Forji/Views/MergedRepositoryListView.swift @@ -99,11 +99,11 @@ struct MergedRepositoryListView: View { let client = source.client group.addTask { let service = RepositoryService(client: client) - return (index, try await resilientFetch { + return try await (index, resilientFetch { if query.isEmpty { - return try await service.fetchUserRepositories(page: page, limit: limit) + try await service.fetchUserRepositories(page: page, limit: limit) } else { - return try await service.searchRepositories(query: query, page: page, limit: limit) + try await service.searchRepositories(query: query, page: page, limit: limit) } }) } diff --git a/Forji/Forji/Views/MergedSearchableOverviewView.swift b/Forji/Forji/Views/MergedSearchableOverviewView.swift index 7c13c36..3645119 100644 --- a/Forji/Forji/Views/MergedSearchableOverviewView.swift +++ b/Forji/Forji/Views/MergedSearchableOverviewView.swift @@ -211,7 +211,7 @@ struct MergedSearchableOverviewView: for flag in flags { group.addTask { let service = IssueService(client: client) - return (index, try await resilientFetch { + return try await (index, resilientFetch { try await service.searchIssues( type: issueType, state: state, page: page, limit: limit, assigned: flag == .assigned, created: flag == .created, @@ -224,7 +224,7 @@ struct MergedSearchableOverviewView: } else { group.addTask { let service = IssueService(client: client) - return (index, try await resilientFetch { + return try await (index, resilientFetch { try await service.searchIssues( type: issueType, state: state, query: query, page: page, limit: limit, diff --git a/Forji/Forji/Views/NotificationsOverviewView.swift b/Forji/Forji/Views/NotificationsOverviewView.swift index b69743b..ed97d52 100644 --- a/Forji/Forji/Views/NotificationsOverviewView.swift +++ b/Forji/Forji/Views/NotificationsOverviewView.swift @@ -5,6 +5,7 @@ struct NotificationsOverviewView: View { @State private var authService: AuthenticationService @State private var pagination = PaginationState() @State private var statusFilter: String = "unread" + @Environment(NavigationState.self) private var navigationState private let notificationService: NotificationService? @@ -96,6 +97,9 @@ struct NotificationsOverviewView: View { .onChange(of: statusFilter) { reloadNotifications(clearItems: true) } + .onChange(of: navigationState.notificationsRefreshTrigger) { + reloadNotifications() + } .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) } @@ -190,6 +194,7 @@ struct NotificationsOverviewView: View { ) } } + NotificationCenter.default.post(name: .notificationsDidChange, object: nil) } catch { pagination.errorMessage = error.localizedDescription pagination.showError = true diff --git a/Forji/Forji/Views/SettingsTabView.swift b/Forji/Forji/Views/SettingsTabView.swift new file mode 100644 index 0000000..82fdcaa --- /dev/null +++ b/Forji/Forji/Views/SettingsTabView.swift @@ -0,0 +1,307 @@ +import BackgroundTasks +import ForgejoKit +import SwiftData +import SwiftUI +import UserNotifications + +// MARK: - Settings Tab + +struct SettingsTabView: View { + @AppStorage("appearance") private var appearance: AppAppearance = .system + @AppStorage("notificationsEnabled") private var notificationsEnabled = false + @State private var authService: AuthenticationService + @Environment(\.modelContext) private var modelContext + + init(authService: AuthenticationService) { + self.authService = authService + } + + var body: some View { + List { + Section("Notifications") { + Toggle("Push Notifications (Beta)", isOn: Binding( + get: { notificationsEnabled }, + set: { newValue in + if newValue { + Task { await enableNotifications() } + } else { + Task { await disableNotifications() } + } + }, + )) + .accessibilityIdentifier("notifications-toggle") + } + + Section("Appearance") { + Picker("Theme", selection: $appearance) { + Text("System").tag(AppAppearance.system) + Text("Light").tag(AppAppearance.light) + Text("Dark").tag(AppAppearance.dark) + } + .pickerStyle(.inline) + .labelsHidden() + } + + if let user = authService.currentUser { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(user.fullName ?? user.login) + .font(.headline) + Text("@\(user.login)") + .font(.subheadline) + .foregroundStyle(.secondary) + if let email = user.email { + Text(email) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + } + } + + if let instance = authService.currentInstance { + Section("Instance") { + LabeledContent("Server", value: instance.serverURL) + LabeledContent("User", value: instance.username) + } + } + + Section { + Button { + authService.disconnect() + } label: { + Label("Switch Instance", systemImage: "arrow.triangle.swap") + } + .accessibilityIdentifier("home-switch-instance-button") + + Button(role: .destructive) { + Task { await authService.logout(modelContext: modelContext) } + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + } + .accessibilityIdentifier("home-logout-button") + } + + AboutSection() + } + .navigationTitle("Settings") + } + + private func enableNotifications() async { + let center = UNUserNotificationCenter.current() + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted { + notificationsEnabled = true + if let instance = authService.currentInstance { + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + if let client = authService.client { + let service = NotificationService(client: client) + let unread = try await service.fetchNotifications(statusTypes: ["unread"], page: 1, limit: 50) + let ids = Set(unread.map(\.id)) + SeenNotificationStore().seed(ids: ids, for: normalizedURL, username: instance.username) + } + } + NotificationPollingService.shared.scheduleBackgroundPoll() + } else { + notificationsEnabled = false + } + } catch { + notificationsEnabled = false + } + } +} + +// MARK: - Merged Settings Tab + +struct MergedSettingsTabView: View { + @AppStorage("appearance") private var appearance: AppAppearance = .system + @AppStorage("notificationsEnabled") private var notificationsEnabled = false + @AppStorage("defaultAllInstances") private var defaultAllInstances = false + @Environment(\.modelContext) private var modelContext + @Query private var allInstances: [ForgejoInstance] + let manager: MultiInstanceManager + var onDisconnect: (() -> Void)? + + var body: some View { + List { + Section("Notifications") { + Toggle("Push Notifications (Beta)", isOn: Binding( + get: { notificationsEnabled }, + set: { newValue in + if newValue { + Task { await enableNotifications() } + } else { + Task { await disableNotifications() } + } + }, + )) + .accessibilityIdentifier("notifications-toggle") + } + + Section("Appearance") { + Picker("Theme", selection: $appearance) { + Text("System").tag(AppAppearance.system) + Text("Light").tag(AppAppearance.light) + Text("Dark").tag(AppAppearance.dark) + } + .pickerStyle(.inline) + .labelsHidden() + } + + Section("Connected Instances") { + ForEach(manager.connections, id: \.instance.id) { connection in + VStack(alignment: .leading, spacing: 4) { + Text(manager.displayName(for: connection.instance)) + .font(.headline) + Text(connection.instance.serverURL) + .font(.subheadline) + .foregroundStyle(.secondary) + Text("@\(connection.instance.username)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + + if !manager.failedInstances.isEmpty { + Section("Failed Connections") { + ForEach(manager.failedInstances, id: \.instance.id) { failed in + VStack(alignment: .leading, spacing: 4) { + Text(manager.displayName(for: failed.instance)) + .font(.headline) + Text(failed.error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + Section { + Toggle("Default on Launch", isOn: $defaultAllInstances) + .onChange(of: defaultAllInstances) { _, isOn in + if isOn { + ForgejoInstance.clearDefaults(in: allInstances) + try? modelContext.save() + } + } + } footer: { + Text("Automatically connect to all instances when the app launches.") + .font(.caption) + } + + Section { + Button { + onDisconnect?() + } label: { + Label("Switch Instance", systemImage: "arrow.triangle.swap") + } + .accessibilityIdentifier("home-switch-instance-button") + } + + AboutSection() + } + .navigationTitle("Settings") + } + + private func enableNotifications() async { + let center = UNUserNotificationCenter.current() + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted { + notificationsEnabled = true + for connection in manager.connections { + let instance = connection.instance + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + if let client = connection.authService.client { + let service = NotificationService(client: client) + let unread = try await service.fetchNotifications(statusTypes: ["unread"], page: 1, limit: 50) + let ids = Set(unread.map(\.id)) + SeenNotificationStore().seed(ids: ids, for: normalizedURL, username: instance.username) + } + } + NotificationPollingService.shared.scheduleBackgroundPoll() + } else { + notificationsEnabled = false + } + } catch { + notificationsEnabled = false + } + } +} + +// MARK: - Disable Notifications + +private func disableNotifications() async { + UserDefaults.standard.set(false, forKey: "notificationsEnabled") + await NotificationPollingService.shared.stopForegroundPolling() + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: NotificationPollingService.backgroundTaskIdentifier) + try? await UNUserNotificationCenter.current().setBadgeCount(0) +} + +// MARK: - About Section + +struct AboutSection: View { + var body: some View { + Section("About") { + LabeledContent("Copyright", value: "Stefan Hausotte") + + VStack(alignment: .leading, spacing: 8) { + Text("License") + .font(.subheadline) + .foregroundStyle(.secondary) + // swiftlint:disable:next line_length + Text("Forji is free software licensed under the GNU General Public License v3.0 or later (GPLv3+). You are free to use, modify, and redistribute it under the terms of that license.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + Text("Logo") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("The Forji logo is based on the Forgejo logo by Caesar Schinas, licensed under CC BY-SA 4.0.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + Text("Used Libraries") + .font(.subheadline) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Link("ForgejoKit (MIT)", destination: URL(string: "https://codeberg.org/secana/ForgejoKit")!) + Link("Textual (MIT)", destination: URL(string: "https://github.com/gonzalezreal/textual")!) + Link( + "HighlightSwift (MIT)", + destination: URL(string: "https://github.com/appstefan/HighlightSwift")!, + ) + Link( + "mermaid (MIT)", + destination: URL(string: "https://github.com/mermaid-js/mermaid")!, + ) + Link( + "marked (MIT)", + destination: URL(string: "https://github.com/markedjs/marked")!, + ) + Link( + "DOMPurify (Apache 2.0/MPL 2.0)", + destination: URL(string: "https://github.com/cure53/DOMPurify")!, + ) + Link( + "github-markdown-css (MIT)", + destination: URL(string: "https://github.com/sindresorhus/github-markdown-css")!, + ) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } +} diff --git a/Forji/ForjiTests/CacheInvalidationTests.swift b/Forji/ForjiTests/CacheInvalidationTests.swift index 0b5e75c..af27951 100644 --- a/Forji/ForjiTests/CacheInvalidationTests.swift +++ b/Forji/ForjiTests/CacheInvalidationTests.swift @@ -6,5 +6,7 @@ struct CacheInvalidationTests { @Test func notificationNamesAreDistinct() { #expect(Notification.Name.issuesDidChange != Notification.Name.pullRequestsDidChange) + #expect(Notification.Name.notificationsDidChange != Notification.Name.issuesDidChange) + #expect(Notification.Name.notificationsDidChange != Notification.Name.pullRequestsDidChange) } } diff --git a/Forji/ForjiTests/NavigationStateTests.swift b/Forji/ForjiTests/NavigationStateTests.swift new file mode 100644 index 0000000..07cf72c --- /dev/null +++ b/Forji/ForjiTests/NavigationStateTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import Forji + +@MainActor +struct NavigationStateTests { + @Test func navigateToNotificationsSetsCorrectValues() { + let state = NavigationState.shared + state.consume() // reset + + state.navigateToNotifications(serverURL: "https://forgejo.example.com", username: "alice") + + #expect(state.pendingNavigation != nil) + #expect(state.pendingNavigation?.serverURL == "https://forgejo.example.com") + #expect(state.pendingNavigation?.username == "alice") + #expect(state.pendingNavigation?.targetTab == 3) + } + + @Test func consumeClearsPendingNavigation() { + let state = NavigationState.shared + + state.navigateToNotifications(serverURL: "https://forgejo.example.com", username: "alice") + #expect(state.pendingNavigation != nil) + + state.consume() + #expect(state.pendingNavigation == nil) + } + + @Test func navigateIncrementsRefreshTrigger() { + let state = NavigationState.shared + let before = state.notificationsRefreshTrigger + + state.navigateToNotifications(serverURL: "https://forgejo.example.com", username: "alice") + #expect(state.notificationsRefreshTrigger == before + 1) + + state.navigateToNotifications(serverURL: "https://forgejo.example.com", username: "alice") + #expect(state.notificationsRefreshTrigger == before + 2) + } + + @Test func consumeDoesNotResetRefreshTrigger() { + let state = NavigationState.shared + state.navigateToNotifications(serverURL: "https://forgejo.example.com", username: "alice") + let triggerAfterNav = state.notificationsRefreshTrigger + + state.consume() + #expect(state.notificationsRefreshTrigger == triggerAfterNav) + } +} diff --git a/Forji/ForjiTests/NotificationPollingIntegrationTests.swift b/Forji/ForjiTests/NotificationPollingIntegrationTests.swift new file mode 100644 index 0000000..b609c52 --- /dev/null +++ b/Forji/ForjiTests/NotificationPollingIntegrationTests.swift @@ -0,0 +1,540 @@ +import ForgejoKit +import Foundation +import Testing +import UserNotifications + +@testable import Forji + +/// Integration tests for notification polling components against a real Docker Forgejo instance. +/// These tests require Docker containers to be running (`just docker-up && just seed`). +/// Serialized because some tests mark notifications as read on the server. +@Suite(.serialized) +struct NotificationPollingIntegrationTests { + + private static let username = "testadmin" + private static let password = "admin1234" + + /// Reads the Forgejo test server URL from the temp file written by the justfile. + private static func resolveServerURL() -> String? { + let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "") + .replacingOccurrences(of: " ", with: "_") + let specificPath = "/tmp/forgejo_test_url_\(deviceName).txt" + let genericPath = "/tmp/forgejo_test_url.txt" + + return ((try? String(contentsOfFile: specificPath, encoding: .utf8)) + ?? (try? String(contentsOfFile: genericPath, encoding: .utf8))) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } + + /// Creates a ForgejoClient authenticated with testadmin's token. + private static func makeClient() async throws -> (ForgejoClient, String) { + guard let serverURL = resolveServerURL() else { + throw IntegrationTestError.serverNotRunning + } + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + let result = try await ForgejoClient.login( + serverURL: normalizedURL, + username: username, + password: password, + ) + return (result.client, normalizedURL) + } + + // MARK: - Fetch Notifications from Real Server + + @Test(.enabled(if: resolveServerURL() != nil)) + func fetchNotificationsReturnsResults() async throws { + let (client, _) = try await Self.makeClient() + let service = NotificationService(client: client) + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + + // The seeder creates issues and PRs from testbot on test-repo, + // which generates notifications for testadmin (repo owner) + #expect(!notifications.isEmpty, "Expected at least one unread notification from seeded data") + #expect(notifications.first?.repository.fullName == "testadmin/test-repo") + } + + // MARK: - SeenNotificationStore with Real Notification IDs + + @Test(.enabled(if: resolveServerURL() != nil)) + func seenStoreTracksRealNotificationIDs() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let ids = Set(notifications.map(\.id)) + #expect(!ids.isEmpty) + + // Seed — should record all current IDs + store.seed(ids: ids, for: normalizedURL, username: Self.username) + #expect(store.isSeeded(for: normalizedURL, username: Self.username)) + #expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids) + + // Simulate a poll returning the same IDs — no new notifications + let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username)) + #expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty") + } + + @Test(.enabled(if: resolveServerURL() != nil)) + func seenStoreDetectsNewNotification() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let allIDs = Set(notifications.map(\.id)) + #expect(allIDs.count >= 2, "Need at least 2 notifications for this test") + + // Seed with only a subset — simulates having seen all but one + let partial = Set(allIDs.dropFirst()) + store.seed(ids: partial, for: normalizedURL, username: Self.username) + + // Now diff — the first ID should show as "new" + let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username) + let newIDs = allIDs.subtracting(seenIDs) + #expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed") + #expect(allIDs.first.map { newIDs.contains($0) } == true) + } + + // MARK: - Prune with Real Data + + @Test(.enabled(if: resolveServerURL() != nil)) + func pruneRemovesIDsNoLongerOnServer() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let serverIDs = Set(notifications.map(\.id)) + + // Seed with server IDs plus a fake stale ID + var seeded = serverIDs + seeded.insert(999_999) + store.seed(ids: seeded, for: normalizedURL, username: Self.username) + #expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999)) + + // Prune — only keep IDs that the server still reports as unread + store.prune(keeping: serverIDs, for: normalizedURL, username: Self.username) + #expect(!store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999)) + #expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs) + } + + // MARK: - Local Notification Posting + + @Test(.enabled(if: resolveServerURL() != nil)) + func pollPostsLocalNotificationsForNewItems() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + #expect(!notifications.isEmpty) + + // Store the token in Keychain so the polling service can find it + let token = try await KeychainManager.shared.getToken( + for: normalizedURL, username: Self.username + ) + + // Use a fresh seen store that hasn't been seeded → first poll should seed + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + #expect(!store.isSeeded(for: normalizedURL, username: Self.username)) + + // Seed the store — first poll seeds without posting notifications + let firstPollIDs = Set(notifications.map(\.id)) + store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username) + + // Verify post-seed state + #expect(store.isSeeded(for: normalizedURL, username: Self.username)) + #expect(store.seenIDs(for: normalizedURL, username: Self.username) == firstPollIDs) + + // Clean up keychain entry we might have added + // (the login in makeClient already saved it) + _ = token + } + + // MARK: - Unread Count for Badge + + @Test(.enabled(if: resolveServerURL() != nil)) + func unreadCountMatchesNotificationList() async throws { + let (client, _) = try await Self.makeClient() + let service = NotificationService(client: client) + + let count = try await service.fetchUnreadCount() + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + + // The unread count API should match the number of unread notifications + // (assuming ≤50 total unreads, which is true for seeded data) + #expect(count == notifications.count, "Badge count should match notification list count") + } + + // MARK: - Keychain Accessibility Migration + + @Test(.enabled(if: resolveServerURL() != nil)) + func keychainMigrationPreservesToken() async throws { + let server = "https://migration-test-\(UUID().uuidString).example.com" + let user = "migrationuser" + let token = "test-token-value" + + // Save with current (post-migration) accessibility + try await KeychainManager.shared.saveToken(token, for: server, username: user) + + // Run migration — should read + delete + re-save without losing data + await KeychainManager.shared.migrateAccessibility(for: server, username: user) + + // Verify token is still accessible + let retrieved = try await KeychainManager.shared.getToken(for: server, username: user) + #expect(retrieved == token) + + // Clean up + try await KeychainManager.shared.deleteToken(for: server, username: user) + } + + @Test(.enabled(if: resolveServerURL() != nil)) + func keychainMigrationPreservesPassword() async throws { + let server = "https://migration-test-\(UUID().uuidString).example.com" + let user = "migrationuser" + let password = "test-password" + + try await KeychainManager.shared.savePassword(password, for: server, username: user) + await KeychainManager.shared.migrateAccessibility(for: server, username: user) + + let retrieved = try await KeychainManager.shared.getPassword(for: server, username: user) + #expect(retrieved == password) + + // Clean up + try await KeychainManager.shared.deletePassword(for: server, username: user) + } + + // MARK: - End-to-End: Full Poll Cycle + + @Test(.enabled(if: resolveServerURL() != nil)) + func fullPollCycleWithSeenStoreDiffAndPrune() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + + // --- First poll: seed --- + let firstPoll = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let firstIDs = Set(firstPoll.map(\.id)) + #expect(!firstIDs.isEmpty) + + // Not seeded yet → seed + #expect(!store.isSeeded(for: normalizedURL, username: Self.username)) + store.seed(ids: firstIDs, for: normalizedURL, username: Self.username) + #expect(store.isSeeded(for: normalizedURL, username: Self.username)) + + // --- Second poll: same data, no new notifications --- + let secondPoll = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let secondIDs = Set(secondPoll.map(\.id)) + let newAfterSecond = secondIDs.subtracting( + store.seenIDs(for: normalizedURL, username: Self.username) + ) + #expect(newAfterSecond.isEmpty, "No new notifications expected on second poll with same data") + + // Mark seen and prune + store.markSeen(secondIDs, for: normalizedURL, username: Self.username) + store.prune(keeping: secondIDs, for: normalizedURL, username: Self.username) + + // Store should contain exactly the server-side IDs + #expect(store.seenIDs(for: normalizedURL, username: Self.username) == secondIDs) + } + + // MARK: - Badge drops after marking as read + + @Test(.enabled(if: resolveServerURL() != nil)) + func badgeCountDropsAfterMarkingRead() async throws { + let (client, _) = try await Self.makeClient() + let service = NotificationService(client: client) + + let notifications = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + try #require(!notifications.isEmpty, "Need at least one unread notification") + + let before = try await service.fetchUnreadCount() + try await service.markAsRead(id: notifications[0].id) + + let after = try await service.fetchUnreadCount() + #expect(after == before - 1, "Unread count should decrease by 1 after marking one as read") + } + + // MARK: - Seen store prunes after mark-read on server + + @Test(.enabled(if: resolveServerURL() != nil)) + func seenStorePrunesAfterMarkRead() async throws { + let (client, normalizedURL) = try await Self.makeClient() + let service = NotificationService(client: client) + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + + // Initial poll — seed seen store + let initial = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + try #require(initial.count >= 2, "Need at least 2 unread notifications") + let initialIDs = Set(initial.map(\.id)) + store.seed(ids: initialIDs, for: normalizedURL, username: Self.username) + + // Mark one as read on the server + let markedID = initial[0].id + try await service.markAsRead(id: markedID) + + // Next poll — fewer unreads + let afterMark = try await service.fetchNotifications( + statusTypes: ["unread"], page: 1, limit: 50 + ) + let afterIDs = Set(afterMark.map(\.id)) + #expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread") + + // Prune — the marked ID should be removed from seen store + store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username) + #expect( + !store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID), + "Pruned seen store should not contain the marked-as-read ID" + ) + #expect(store.seenIDs(for: normalizedURL, username: Self.username) == afterIDs) + } + + // MARK: - Seen store dedup and foreground poll behavior + + @Test + func seenStoreDeduplicatesAcrossPolls() async throws { + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + let server = "https://test.example.com" + let user = "testuser" + + // Seed with partial IDs — ID 1 is "seen", ID 2 is "new" + store.seed(ids: [1], for: server, username: user) + + // Simulate a poll returning IDs [1, 2] + let currentIDs: Set = [1, 2] + let seenIDs = store.seenIDs(for: server, username: user) + let newIDs = currentIDs.subtracting(seenIDs) + + // New IDs should be detected + #expect(newIDs == [2], "Should detect ID 2 as new") + + // Mark new IDs as seen and prune + store.markSeen(newIDs, for: server, username: user) + store.prune(keeping: currentIDs, for: server, username: user) + + // After poll, all IDs should be seen + #expect(store.seenIDs(for: server, username: user) == [1, 2]) + + // A subsequent poll with the same IDs should find no new notifications + let nextNewIDs = currentIDs.subtracting(store.seenIDs(for: server, username: user)) + #expect(nextNewIDs.isEmpty, "Second poll should find nothing new") + } + + // MARK: - Background Download Request Construction + + @Test(.enabled(if: resolveServerURL() != nil)) + func backgroundDownloadBuildsCorrectRequest() async throws { + guard let serverURL = Self.resolveServerURL() else { + throw IntegrationTestError.serverNotRunning + } + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + + let token = try await { + _ = try await ForgejoClient.login( + serverURL: normalizedURL, + username: Self.username, + password: Self.password + ) + return try await KeychainManager.shared.getToken( + for: normalizedURL, username: Self.username + ) + }() + + // Build the same URL that BackgroundDownloadManager would + var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")! + components.queryItems = [ + URLQueryItem(name: "status-types", value: "unread"), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "50"), + ] + let url = try #require(components.url) + + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + + // Verify URL structure + #expect(url.path.hasSuffix("/api/v1/notifications")) + #expect(url.query?.contains("status-types=unread") == true) + #expect(url.query?.contains("page=1") == true) + #expect(url.query?.contains("limit=50") == true) + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("token ") == true) + } + + // MARK: - Background Download Response Parsing + + @Test(.enabled(if: resolveServerURL() != nil)) + func backgroundDownloadParsesNotificationResponse() async throws { + guard let serverURL = Self.resolveServerURL() else { + throw IntegrationTestError.serverNotRunning + } + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + + _ = try await ForgejoClient.login( + serverURL: normalizedURL, + username: Self.username, + password: Self.password + ) + let token = try await KeychainManager.shared.getToken( + for: normalizedURL, username: Self.username + ) + + // Make the same request BackgroundDownloadManager would + var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")! + components.queryItems = [ + URLQueryItem(name: "status-types", value: "unread"), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "50"), + ] + + var request = URLRequest(url: components.url!) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + + // Parse with the same decoder BackgroundDownloadManager uses + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = forgejoDateDecodingStrategy + let notifications = try decoder.decode([NotificationThread].self, from: data) + + #expect(!notifications.isEmpty, "Expected unread notifications from seeded data") + #expect(notifications.first?.repository.fullName == "testadmin/test-repo") + } + + // MARK: - Background Download Detects New Notifications + + @Test(.enabled(if: resolveServerURL() != nil)) + func backgroundDownloadDetectsNewNotifications() async throws { + guard let serverURL = Self.resolveServerURL() else { + throw IntegrationTestError.serverNotRunning + } + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + + _ = try await ForgejoClient.login( + serverURL: normalizedURL, + username: Self.username, + password: Self.password + ) + let token = try await KeychainManager.shared.getToken( + for: normalizedURL, username: Self.username + ) + + // Fetch notifications the same way BackgroundDownloadManager does + var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")! + components.queryItems = [ + URLQueryItem(name: "status-types", value: "unread"), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "50"), + ] + + var request = URLRequest(url: components.url!) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + + let (data, _) = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = forgejoDateDecodingStrategy + let notifications = try decoder.decode([NotificationThread].self, from: data) + let allIDs = Set(notifications.map(\.id)) + #expect(allIDs.count >= 2, "Need at least 2 notifications") + + // Seed with a subset — simulate having seen all but one + let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") + let partial = Set(allIDs.dropFirst()) + store.seed(ids: partial, for: normalizedURL, username: Self.username) + + // Diff — should detect the missing ID as new + let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username) + let newIDs = allIDs.subtracting(seenIDs) + #expect(newIDs.count == 1, "Expected exactly 1 new notification") + } + + // MARK: - Background Download Updates Badge + + @Test(.enabled(if: resolveServerURL() != nil)) + func backgroundDownloadUpdatesBadge() async throws { + guard let serverURL = Self.resolveServerURL() else { + throw IntegrationTestError.serverNotRunning + } + let normalizedURL = ForgejoClient.normalizeServerURL(serverURL) + + _ = try await ForgejoClient.login( + serverURL: normalizedURL, + username: Self.username, + password: Self.password + ) + let token = try await KeychainManager.shared.getToken( + for: normalizedURL, username: Self.username + ) + + // Fetch unread count via raw API (same as BackgroundDownloadManager) + var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")! + components.queryItems = [ + URLQueryItem(name: "status-types", value: "unread"), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "50"), + ] + + var request = URLRequest(url: components.url!) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + + let (data, _) = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = forgejoDateDecodingStrategy + let notifications = try decoder.decode([NotificationThread].self, from: data) + + // The badge count should equal the number of unread notifications + let expectedBadge = notifications.count + #expect(expectedBadge > 0, "Expected at least one unread notification for badge test") + + // Also verify via the count API + let (client, _) = try await Self.makeClient() + let service = NotificationService(client: client) + let apiCount = try await service.fetchUnreadCount() + #expect(apiCount == expectedBadge, "Badge count should match notification list") + } + + // MARK: - Task Description Round Trip (Unit Test) + + @Test + func taskDescriptionRoundTrips() throws { + let metadata = InstanceMetadata( + serverURL: "https://codeberg.org", + username: "testuser", + name: "Codeberg" + ) + + let encoded = try JSONEncoder().encode(metadata) + let decoded = try JSONDecoder().decode(InstanceMetadata.self, from: encoded) + + #expect(decoded.serverURL == metadata.serverURL) + #expect(decoded.username == metadata.username) + #expect(decoded.name == metadata.name) + } +} + +private enum IntegrationTestError: Error { + case serverNotRunning +} diff --git a/Forji/ForjiTests/SeenNotificationStoreTests.swift b/Forji/ForjiTests/SeenNotificationStoreTests.swift new file mode 100644 index 0000000..b3bb34d --- /dev/null +++ b/Forji/ForjiTests/SeenNotificationStoreTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +import UserNotifications + +@testable import Forji + +struct SeenNotificationStoreTests { + private func makeStore() -> SeenNotificationStore { + let suite = "de.hausotte.Forji.test.\(UUID().uuidString)" + return SeenNotificationStore(suiteName: suite) + } + + @Test func seedSetsIDsAndMarksAsSeeded() { + let store = makeStore() + let server = "https://example.com" + let user = "alice" + + #expect(!store.isSeeded(for: server, username: user)) + + store.seed(ids: [1, 2, 3], for: server, username: user) + + #expect(store.isSeeded(for: server, username: user)) + #expect(store.seenIDs(for: server, username: user) == [1, 2, 3]) + } + + @Test func markSeenIsAdditive() { + let store = makeStore() + let server = "https://example.com" + let user = "alice" + + store.seed(ids: [1, 2], for: server, username: user) + store.markSeen([3, 4], for: server, username: user) + + #expect(store.seenIDs(for: server, username: user) == [1, 2, 3, 4]) + } + + @Test func pruneRemovesStaleIDs() { + let store = makeStore() + let server = "https://example.com" + let user = "alice" + + store.seed(ids: [1, 2, 3, 4, 5], for: server, username: user) + store.prune(keeping: [2, 4], for: server, username: user) + + #expect(store.seenIDs(for: server, username: user) == [2, 4]) + } + + @Test func perInstanceIsolation() { + let store = makeStore() + + store.seed(ids: [1, 2], for: "https://a.com", username: "alice") + store.seed(ids: [10, 20], for: "https://b.com", username: "bob") + + #expect(store.seenIDs(for: "https://a.com", username: "alice") == [1, 2]) + #expect(store.seenIDs(for: "https://b.com", username: "bob") == [10, 20]) + } + + @Test func emptyStoreReturnsEmptySet() { + let store = makeStore() + #expect(store.seenIDs(for: "https://x.com", username: "nobody").isEmpty) + } + + @Test func isSeededReturnsFalseForUnknownInstance() { + let store = makeStore() + #expect(!store.isSeeded(for: "https://x.com", username: "nobody")) + } + + @Test func markSeenOnUnseededStoreWorks() { + let store = makeStore() + let server = "https://example.com" + let user = "alice" + + store.markSeen([5, 6], for: server, username: user) + #expect(store.seenIDs(for: server, username: user) == [5, 6]) + } + + @Test @MainActor func disableNotificationsClearsFlag() async { + let key = "notificationsEnabled" + UserDefaults.standard.set(true, forKey: key) + #expect(UserDefaults.standard.bool(forKey: key) == true) + + await NotificationPollingService.shared.stopForegroundPolling() + UserDefaults.standard.set(false, forKey: key) + try? await UNUserNotificationCenter.current().setBadgeCount(0) + + #expect(UserDefaults.standard.bool(forKey: key) == false) + } +} diff --git a/Forji/Info.plist b/Forji/Info.plist index 6a6654d..e593424 100644 --- a/Forji/Info.plist +++ b/Forji/Info.plist @@ -7,5 +7,13 @@ NSAllowsArbitraryLoads + BGTaskSchedulerPermittedIdentifiers + + de.hausotte.Forji.notificationPoll + + UIBackgroundModes + + fetch +