From 2b6f64f0bb047f0abdcc94fd4e3735466a7122f9 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 20:43:44 +0800 Subject: [PATCH] fix(notifications): request authorization on first activation --- .../NotificationPreferencesTests.swift | 76 ++++++++++++++++++- FrameworkTests/SystemNotificationsTests.swift | 11 +++ .../NotificationSettingsModel.swift | 10 +++ .../InitialNotificationAuthorization.swift | 21 +++++ TFI/App/MobileRootView.swift | 1 + TFM/App/RootView.swift | 1 + 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 Frameworks/TermindPresentation/InitialNotificationAuthorization.swift diff --git a/FrameworkTests/NotificationPreferencesTests.swift b/FrameworkTests/NotificationPreferencesTests.swift index e6be4ea1..316e2760 100644 --- a/FrameworkTests/NotificationPreferencesTests.swift +++ b/FrameworkTests/NotificationPreferencesTests.swift @@ -9,6 +9,49 @@ import Testing @Suite @MainActor struct NotificationPreferencesTests { + @Test func initialAuthorizationIsRequestedOnceAcrossConcurrentScenesAndReactivation() async { + let model = NotificationSettingsModel() + let service = InitialAuthorizationService() + service.holdAuthorization = true + let firstScene = Task { await model.requestInitialAuthorization(using: service) } + await service.nextRequest() + await model.requestInitialAuthorization(using: service) + #expect(service.requests == [true]) + service.finishRequest() + await firstScene.value + await model.requestInitialAuthorization(using: service) + #expect(service.requests == [true]) + } + + @Test(arguments: [false, true]) + func initialAuthorizationRespectsDisabledNotificationsAndBellOnlyDelivery(bellOnly: Bool) async { + let store = NotificationStore() + if bellOnly { + store.value.assistantMode = .never + store.value.sftpMode = .never + store.value.terminalDelivery = .bell + } else { + store.value.isEnabled = false + } + let model = NotificationSettingsModel(persistence: store) + let service = InitialAuthorizationService() + await model.requestInitialAuthorization(using: service) + #expect(service.requests.isEmpty) + model.applyRemoteSnapshot(.init()) + await model.requestInitialAuthorization(using: service) + #expect(service.requests == [true]) + } + + @Test func failedPreferenceLoadDoesNotRequestAuthorizationUsingDefaults() async { + let store = NotificationStore() + store.fails = true + let model = NotificationSettingsModel(persistence: store) + let service = InitialAuthorizationService() + await model.requestInitialAuthorization(using: service) + #expect(model.storageError != nil) + #expect(service.requests.isEmpty) + } + @Test func defaultsAndIndependentModesAndPermissionRequirements() { var value = NotificationPreferences() #expect(value.isEnabled) @@ -145,13 +188,44 @@ struct NotificationPreferencesTests { @MainActor private final class NotificationStore: NotificationPreferencesPersisting { var value = NotificationPreferences() var fails = false - func loadNotificationPreferences() throws -> NotificationPreferences { value } + func loadNotificationPreferences() throws -> NotificationPreferences { + if fails { throw CocoaError(.fileReadUnknown) } + return value + } func saveNotificationPreferences(_ value: NotificationPreferences) throws { if fails { throw CocoaError(.fileWriteUnknown) } self.value = value } } +@MainActor private final class InitialAuthorizationService: NotificationSending { + var requests: [Bool] = [] + var holdAuthorization = false + private var pending: CheckedContinuation? + private let arrivals = AsyncStream.makeStream() + + func authorization(request: Bool) async -> NotificationAuthorization { + requests.append(request) + if holdAuthorization { + await withCheckedContinuation { + pending = $0 + arrivals.continuation.yield(()) + } + } + return .notDetermined + } + func nextRequest() async { + var iterator = arrivals.stream.makeAsyncIterator() + _ = await iterator.next() + } + func finishRequest() { + pending?.resume() + pending = nil + } + func send(_ notification: AppNotification, ifCurrent: @escaping @MainActor () -> Bool) async {} + func remove(_ identifiers: [String]) {} +} + @MainActor private struct NotificationFixture { let domain = "NotificationPreferencesTests.\(UUID())" let defaults: UserDefaults diff --git a/FrameworkTests/SystemNotificationsTests.swift b/FrameworkTests/SystemNotificationsTests.swift index 959a8d14..2ddff26f 100644 --- a/FrameworkTests/SystemNotificationsTests.swift +++ b/FrameworkTests/SystemNotificationsTests.swift @@ -7,6 +7,17 @@ import UserNotifications @Suite @MainActor struct SystemNotificationsTests { + @Test(arguments: [UNAuthorizationStatus.authorized, .denied, .provisional]) + func configuredAuthorizationDoesNotPromptAgain(status: UNAuthorizationStatus) async { + let center = Center() + center.status = status + let service = SystemNotifications(center: center) + let result = await service.authorization(request: true) + #expect(result == (status == .denied ? .denied : .allowed)) + #expect(center.authorizationRequests == 0) + #expect(center.requests.isEmpty) + } + @Test func permissionIsRequestedOnlyExplicitlyAndOnlyWhenUndetermined() async { let center = Center() center.status = .notDetermined diff --git a/Frameworks/TermindApplication/FeatureModels/NotificationSettingsModel.swift b/Frameworks/TermindApplication/FeatureModels/NotificationSettingsModel.swift index 99dd1dab..ddb8fc61 100644 --- a/Frameworks/TermindApplication/FeatureModels/NotificationSettingsModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/NotificationSettingsModel.swift @@ -7,6 +7,7 @@ public final class NotificationSettingsModel { public private(set) var preferences = NotificationPreferences() public private(set) var storageError: String? private let persistence: (any NotificationPreferencesPersisting)? + private var didRequestInitialAuthorization = false public init(persistence: (any NotificationPreferencesPersisting)? = nil) { self.persistence = persistence @@ -35,4 +36,13 @@ public final class NotificationSettingsModel { preferences = value storageError = nil } + + /// Shared by all scenes so becoming active cannot repeat the initial system prompt. + public func requestInitialAuthorization(using service: any NotificationSending) async { + guard !Task.isCancelled, !didRequestInitialAuthorization, + storageError == nil, preferences.usesSystemNotifications + else { return } + didRequestInitialAuthorization = true + _ = await service.authorization(request: true) + } } diff --git a/Frameworks/TermindPresentation/InitialNotificationAuthorization.swift b/Frameworks/TermindPresentation/InitialNotificationAuthorization.swift new file mode 100644 index 00000000..d7c10895 --- /dev/null +++ b/Frameworks/TermindPresentation/InitialNotificationAuthorization.swift @@ -0,0 +1,21 @@ +import SwiftUI +import TermindApplication + +/// Let each platform present its native notification authorization UI once a scene is active. +public struct InitialNotificationAuthorization: ViewModifier { + private let model: NotificationSettingsModel + private let service: any NotificationSending + @Environment(\.scenePhase) private var scenePhase + + public init(model: NotificationSettingsModel, service: any NotificationSending) { + self.model = model + self.service = service + } + + public func body(content: Content) -> some View { + content.task(id: scenePhase) { + guard scenePhase == .active else { return } + await model.requestInitialAuthorization(using: service) + } + } +} diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 7113d3e8..0278d11d 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -40,6 +40,7 @@ struct MobileRootView: View { } } .tabViewStyle(.sidebarAdaptable) + .modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications)) .onChange(of: scene.visibleAssistantConversationID, initial: true) { app.refreshAssistantReadState() } .environment(\.singleLineInputAdapter, { AnyView(MobileSingleLineFieldInput()) }) .sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) { diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 19963001..4ae4bf68 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -206,6 +206,7 @@ struct RootView: View { // No ancestor drop target: pane and Navigator targets must own internal // layout drops without competing for the same text payload. .environment(window) + .modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications)) .modifier(AppReviewPresenter(window: window)) .proPaywallPresenter(app: app, window: window) .focusedSceneValue(\.window, window)