feat: implement iOS notifications #6

Naive implementation for iOS notifications.

Problem: Forgejo does not support push notifications. We need to pull every  X minutes for new notifications. The even bigger problem: iOS does not support background polling. So this is more a "as good as possible" but not good approach.

Reviewed-on: https://codeberg.org/secana/Forji/pulls/23
Co-authored-by: Stefan Hausotte <stefan.hausotte@gmx.de>
Co-committed-by: Stefan Hausotte <stefan.hausotte@gmx.de>
This commit is contained in:
Stefan Hausotte 2026-03-22 17:43:21 +01:00 committed by secana
parent 92a928b2f4
commit 253f3e88d1
22 changed files with 1701 additions and 226 deletions

View file

@ -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)
}
}

View file

@ -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<ForgejoInstance>(predicate: #Predicate<ForgejoInstance> {
$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

View file

@ -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
}
}
}
}

View file

@ -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")
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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<ForgejoInstance>()
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
}

View file

@ -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)

View file

@ -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

View file

@ -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<Void, Never>?
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<ForgejoInstance>()
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
}

View file

@ -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<Int> {
let ids = defaults.array(forKey: key(for: serverURL, username: username)) as? [Int] ?? []
return Set(ids)
}
func markSeen(_ ids: Set<Int>, 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<Int>, 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<Int>, 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))
}
}

View file

@ -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)

View file

@ -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,
)
})
}

View file

@ -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)
}
})
}

View file

@ -211,7 +211,7 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
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<Row: View, Detail: View, CreateView: View>:
} 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,

View file

@ -5,6 +5,7 @@ struct NotificationsOverviewView: View {
@State private var authService: AuthenticationService
@State private var pagination = PaginationState<NotificationThread>()
@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

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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<Int> = [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
}

View file

@ -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)
}
}

View file

@ -7,5 +7,13 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>de.hausotte.Forji.notificationPoll</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict>
</plist>