mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
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:
parent
92a928b2f4
commit
253f3e88d1
22 changed files with 1701 additions and 226 deletions
77
Forji/Forji/App/AppDelegate.swift
Normal file
77
Forji/Forji/App/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
27
Forji/Forji/Models/NavigationState.swift
Normal file
27
Forji/Forji/Models/NavigationState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
271
Forji/Forji/Services/BackgroundDownloadManager.swift
Normal file
271
Forji/Forji/Services/BackgroundDownloadManager.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
Forji/Forji/Services/NotificationPollingService.swift
Normal file
199
Forji/Forji/Services/NotificationPollingService.swift
Normal 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
|
||||
}
|
||||
43
Forji/Forji/Services/SeenNotificationStore.swift
Normal file
43
Forji/Forji/Services/SeenNotificationStore.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
307
Forji/Forji/Views/SettingsTabView.swift
Normal file
307
Forji/Forji/Views/SettingsTabView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
Forji/ForjiTests/NavigationStateTests.swift
Normal file
49
Forji/ForjiTests/NavigationStateTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
540
Forji/ForjiTests/NotificationPollingIntegrationTests.swift
Normal file
540
Forji/ForjiTests/NotificationPollingIntegrationTests.swift
Normal 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
|
||||
}
|
||||
88
Forji/ForjiTests/SeenNotificationStoreTests.swift
Normal file
88
Forji/ForjiTests/SeenNotificationStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue