fix(notifications): request authorization on first activation

This commit is contained in:
Kaede Akino committed 2026-09-27 20:43:44 +08:00
1 parent ae6b8c1beb
commit 2b6f64f0bb
6 files changed
+119 -1

No files matched your search

@@ -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<Void, Never>?
private let arrivals = AsyncStream<Void>.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
@@ -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
@@ -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)
}
}
@@ -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)
}
}
}
+1
View File
@@ -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 })) {
+1
View File
@@ -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)