fix(notifications): request authorization on first activation
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user