diff --git a/ComposableUserNotifications.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableUserNotifications.xcworkspace/xcshareddata/swiftpm/Package.resolved index 95911b1..88c1591 100644 --- a/ComposableUserNotifications.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableUserNotifications.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", "state": { "branch": null, - "revision": "ff42ec9061d864de7982162011321d3df5080c10", - "version": "0.1.2" + "revision": "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", + "version": "0.9.1" } }, { @@ -15,8 +15,26 @@ "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", "state": { "branch": null, - "revision": "ed1838ab4fa5d47db8aa640736dd5b7672548873", - "version": "0.1.2" + "revision": "bb436421f57269fbcfe7360735985321585a86e5", + "version": "0.10.1" + } + }, + { + "package": "swift-clocks", + "repositoryURL": "https://github.com/pointfreeco/swift-clocks", + "state": { + "branch": null, + "revision": "692ec4f5429a667bdd968c7260dfa2b23adfeffc", + "version": "0.1.4" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections", + "state": { + "branch": null, + "revision": "f504716c27d2e5d4144fa4794b12129301d17729", + "version": "1.0.3" } }, { @@ -24,8 +42,35 @@ "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", "state": { "branch": null, - "revision": "b67569f69813140cd9c984db33ee959d9711a008", - "version": "0.9.0" + "revision": "1fcd53fc875bade47d850749ea53c324f74fd64d", + "version": "0.45.0" + } + }, + { + "package": "swift-custom-dump", + "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", + "state": { + "branch": null, + "revision": "819d9d370cd721c9d87671e29d947279292e4541", + "version": "0.6.0" + } + }, + { + "package": "swift-identified-collections", + "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", + "state": { + "branch": null, + "revision": "bfb0d43e75a15b6dfac770bf33479e8393884a36", + "version": "0.4.1" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version": "0.5.0" } } ] diff --git a/Examples/Example/Example.xcodeproj/project.pbxproj b/Examples/Example/Example.xcodeproj/project.pbxproj index 3a768da..da6199a 100644 --- a/Examples/Example/Example.xcodeproj/project.pbxproj +++ b/Examples/Example/Example.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ 4AA682B2254D824B00031ADC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4AA682B1254D824B00031ADC /* Assets.xcassets */; }; 4AA682C0254D824B00031ADC /* ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA682BF254D824B00031ADC /* ExampleTests.swift */; }; 4AA682E0254D827C00031ADC /* ComposableUserNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = 4AA682DF254D827C00031ADC /* ComposableUserNotifications */; }; - 4AA682E7254D870C00031ADC /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA682E6254D870C00031ADC /* AppState.swift */; }; + 4AA682E7254D870C00031ADC /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA682E6254D870C00031ADC /* App.swift */; }; 4AFBD623254EC40900977967 /* Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFBD622254EC40900977967 /* Helper.swift */; }; /* End PBXBuildFile section */ @@ -41,7 +41,7 @@ 4AA682BB254D824B00031ADC /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4AA682BF254D824B00031ADC /* ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTests.swift; sourceTree = ""; }; 4AA682C1254D824B00031ADC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 4AA682E6254D870C00031ADC /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 4AA682E6254D870C00031ADC /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 4AFBD622254EC40900977967 /* Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helper.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -86,7 +86,7 @@ 4AA682AC254D824900031ADC /* Example */ = { isa = PBXGroup; children = ( - 4AA682E6254D870C00031ADC /* AppState.swift */, + 4AA682E6254D870C00031ADC /* App.swift */, 4A19F314254D8B9300E429F0 /* BackgroundNotification.swift */, 4AA682AF254D824900031ADC /* ContentView.swift */, 4AFBD622254EC40900977967 /* Helper.swift */, @@ -216,7 +216,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4AA682E7254D870C00031ADC /* AppState.swift in Sources */, + 4AA682E7254D870C00031ADC /* App.swift in Sources */, 4AFBD623254EC40900977967 /* Helper.swift in Sources */, 4AA682B0254D824900031ADC /* ContentView.swift in Sources */, 4A19F319254D8BB700E429F0 /* UserNotification.swift in Sources */, diff --git a/Examples/Example/Example/App.swift b/Examples/Example/Example/App.swift new file mode 100644 index 0000000..5c3abbe --- /dev/null +++ b/Examples/Example/Example/App.swift @@ -0,0 +1,121 @@ +import Foundation +import ComposableArchitecture +import ComposableUserNotifications +import UIKit + +struct App: ReducerProtocol { + struct State: Equatable { + var count: Int? + } + + enum Action: Equatable { + case addNotificationResponse(TaskResult) + case didFinishLaunching(notification: UserNotification?) + case didReceiveBackgroundNotification(BackgroundNotification) + case remoteCountResponse(TaskResult) + case requestAuthorizationResponse(TaskResult) + case tappedScheduleButton + case userNotifications(UserNotificationClient.DeletegateAction) + } + + @Dependency(\.remote) var remote + @Dependency(\.userNotifications) var userNotifications + + func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case let .didFinishLaunching(notification): + if case let .count(value) = notification { + state.count = value + } + + return .run { send in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await event in self.userNotifications.delegate() { + await send(.userNotifications(event)) + } + } + + group.addTask { + await send( + .requestAuthorizationResponse( + TaskResult { + try await self.userNotifications.requestAuthorization([.alert, .badge, .sound]) + } + ) + ) + } + } + } + + case let .didReceiveBackgroundNotification(backgroundNotification): + let fetchCompletionHandler = backgroundNotification.fetchCompletionHandler + guard backgroundNotification.content == .countAvailable else { + return .fireAndForget { + backgroundNotification.fetchCompletionHandler(.noData) + } + } + + return .task { + do { + let count = try await self.remote.fetchRemoteCount() + fetchCompletionHandler(.newData) + return .remoteCountResponse(.success(count)) + } catch { + fetchCompletionHandler(.failed) + return .remoteCountResponse(.failure(error)) + } + } + + case let .remoteCountResponse(.success(count)): + state.count = count + return .none + + case .remoteCountResponse(.failure): + return .none + + case let .userNotifications(.willPresentNotification(_, completion)): + return .fireAndForget { + completion([.list, .banner, .sound]) + } + + case let .userNotifications(.didReceiveResponse(response, completion)): + let notification = UserNotification(userInfo: response.notification.request.content.userInfo()) + if case let .count(value) = notification { + state.count = value + } + + return .fireAndForget(completion) + + case .userNotifications(.openSettingsForNotification): + return .none + + case .requestAuthorizationResponse: + return .none + + case .addNotificationResponse: + return .none + + case .tappedScheduleButton: + let content = UNMutableNotificationContent() + content.title = "Example title" + content.body = "Example body" + + let request = UNNotificationRequest( + identifier: "example_notification", + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) + ) + + return .task { + await self.userNotifications + .removePendingNotificationRequestsWithIdentifiers(["example_notification"]) + return await .addNotificationResponse( + TaskResult { + Unit(try await self.userNotifications.add(request)) + } + ) + } + } + } +} diff --git a/Examples/Example/Example/AppState.swift b/Examples/Example/Example/AppState.swift deleted file mode 100644 index 86372db..0000000 --- a/Examples/Example/Example/AppState.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// AppState.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - -import Foundation -import ComposableArchitecture -import ComposableUserNotifications -import UIKit - -struct AppState: Equatable { - var count: Int? -} - -enum AppAction: Equatable { - case addNotificationResponse(Result) - case didFinishLaunching(notification: UserNotification?) - case didReceiveBackgroundNotification(BackgroundNotification) - case remoteCountResponse(Result) - case requestAuthorizationResponse(Result) - case tappedScheduleButton - case userNotification(UserNotificationClient.Action) -} - -struct AppEnvironment { - var remoteClient: RemoteClient - var userNotificationClient: UserNotificationClient -} - -let appReducer = Reducer { state, action, environment in - switch action { - case let .didFinishLaunching(notification): - if case let .count(value) = notification { - state.count = value - } - - return .merge( - environment.userNotificationClient - .delegate() - .map(AppAction.userNotification), - environment.userNotificationClient.requestAuthorization([.alert, .badge, .sound]) - .catchToEffect() - .map(AppAction.requestAuthorizationResponse) - ) - - case let .didReceiveBackgroundNotification(backgroundNotification): - let fetchCompletionHandler = backgroundNotification.fetchCompletionHandler - guard backgroundNotification.content == .countAvailable else { - return .fireAndForget { - backgroundNotification.fetchCompletionHandler(.noData) - } - } - - return environment.remoteClient.fetchRemoteCount() - .catchToEffect() - .handleEvents(receiveOutput: { result in - switch result { - case .success: - fetchCompletionHandler(.newData) - case .failure: - fetchCompletionHandler(.failed) - } - }) - .eraseToEffect() - .map(AppAction.remoteCountResponse) - - case let .remoteCountResponse(.success(count)): - state.count = count - return .none - - case .remoteCountResponse(.failure): - return .none - - case let .userNotification(.willPresentNotification(notification, completion)): - return .fireAndForget { - completion([.list, .banner, .sound]) - } - - case let .userNotification(.didReceiveResponse(response, completion)): - let notification = UserNotification(userInfo: response.notification.request.content.userInfo()) - if case let .count(value) = notification { - state.count = value - } - - return .fireAndForget(completion) - - case .userNotification(.openSettingsForNotification): - return .none - - case .requestAuthorizationResponse: - return .none - - case .addNotificationResponse: - return .none - - case .tappedScheduleButton: - let content = UNMutableNotificationContent() - content.title = "Example title" - content.body = "Example body" - - let request = UNNotificationRequest( - identifier: "example_notification", - content: content, - trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) - ) - - return .concatenate( - environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"]) - .fireAndForget(), - environment.userNotificationClient.add(request) - .map(Unit.init) - .catchToEffect() - .map(AppAction.addNotificationResponse) - ) - } -} diff --git a/Examples/Example/Example/BackgroundNotification.swift b/Examples/Example/Example/BackgroundNotification.swift index ea39314..f970786 100644 --- a/Examples/Example/Example/BackgroundNotification.swift +++ b/Examples/Example/Example/BackgroundNotification.swift @@ -1,10 +1,3 @@ -// -// BackgroundNotification.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - import Foundation import UIKit diff --git a/Examples/Example/Example/ContentView.swift b/Examples/Example/Example/ContentView.swift index 28e1171..c83e5b3 100644 --- a/Examples/Example/Example/ContentView.swift +++ b/Examples/Example/Example/ContentView.swift @@ -1,16 +1,9 @@ -// -// ContentView.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - import ComposableArchitecture import ComposableUserNotifications import SwiftUI struct ContentView: View { - let store: Store + let store: StoreOf var body: some View { WithViewStore(self.store) { viewStore in @@ -30,12 +23,8 @@ struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( store: Store( - initialState: AppState(), - reducer: .empty, - environment: AppEnvironment( - remoteClient: RemoteClient(fetchRemoteCount: { Effect(value: 1) }), - userNotificationClient: .mock() - ) + initialState: .init(), + reducer: App() ) ) } diff --git a/Examples/Example/Example/Helper.swift b/Examples/Example/Example/Helper.swift index 0c65942..5b72ac5 100644 --- a/Examples/Example/Example/Helper.swift +++ b/Examples/Example/Example/Helper.swift @@ -1,10 +1,3 @@ -// -// Helper.swift -// Example -// -// Created by Michael Kao on 01.11.20. -// - import Foundation struct Unit: Equatable { diff --git a/Examples/Example/Example/RemoteClient.swift b/Examples/Example/Example/RemoteClient.swift index 4fff45c..9321f94 100644 --- a/Examples/Example/Example/RemoteClient.swift +++ b/Examples/Example/Example/RemoteClient.swift @@ -1,17 +1,31 @@ -// -// RemoteClient.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - import Foundation import ComposableArchitecture +import XCTestDynamicOverlay struct RemoteClient { - var fetchRemoteCount: () -> Effect + var fetchRemoteCount: () async throws -> Int +} - public struct Error: Swift.Error, Equatable { - public init() {} +extension DependencyValues { + var remote: RemoteClient { + get { self[RemoteClient.self] } + set { self[RemoteClient.self] = newValue } } } + +extension RemoteClient: DependencyKey { + static let liveValue = Self( + fetchRemoteCount: { 1 } + ) +} + +extension RemoteClient: TestDependencyKey { + static let previewValue = Self( + fetchRemoteCount: { 666 } + ) + + static let testValue = Self( + fetchRemoteCount: unimplemented("\(Self.self).fetchRemoteCount") + ) +} + diff --git a/Examples/Example/Example/SceneDelegate.swift b/Examples/Example/Example/SceneDelegate.swift index a9b2e12..4e710db 100644 --- a/Examples/Example/Example/SceneDelegate.swift +++ b/Examples/Example/Example/SceneDelegate.swift @@ -1,22 +1,13 @@ -// -// ExampleApp.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - import ComposableArchitecture import SwiftUI import UIKit -import Combine private let store = Store( - initialState: AppState(), - reducer: appReducer, - environment: AppEnvironment( - remoteClient: .randomDelayed, - userNotificationClient: .live - ) + initialState: App.State(), + reducer: App().transformDependency(\.self) { + $0.remote = .liveValue + $0.userNotifications = .liveValue + } ) class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -62,9 +53,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension RemoteClient { static let randomDelayed = RemoteClient( fetchRemoteCount: { - Effect(value: Int.random(in: 0...10)) - .delay(for: 2, scheduler: DispatchQueue.main) - .eraseToEffect() + try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + return Int.random(in: 0...10) } ) } diff --git a/Examples/Example/Example/UserNotification.swift b/Examples/Example/Example/UserNotification.swift index 71d3892..6f1873f 100644 --- a/Examples/Example/Example/UserNotification.swift +++ b/Examples/Example/Example/UserNotification.swift @@ -1,10 +1,3 @@ -// -// UserNotification.swift -// Example -// -// Created by Michael Kao on 31.10.20. -// - import Foundation public enum UserNotification: Equatable { diff --git a/Examples/Example/ExampleTests/ExampleTests.swift b/Examples/Example/ExampleTests/ExampleTests.swift index 72ef093..722d585 100644 --- a/Examples/Example/ExampleTests/ExampleTests.swift +++ b/Examples/Example/ExampleTests/ExampleTests.swift @@ -1,77 +1,70 @@ -// -// ExampleTests.swift -// ExampleTests -// -// Created by Michael Kao on 31.10.20. -// - -import Combine import ComposableArchitecture import ComposableUserNotifications import struct ComposableUserNotifications.Notification import XCTest @testable import Example +@MainActor class ExampleTests: XCTestCase { - var environment = AppEnvironment( - remoteClient: RemoteClient(fetchRemoteCount: { Effect(value: 5) }), - userNotificationClient: .mock( - requestAuthorization: { _ in Effect(value: true) } - ) - ) - - func testApplicationLaunchWithoutNotification() throws { - let delegateActionSubject = PassthroughSubject() - var didSubscribeNotifications = false - var didRequestAuthrizationOptions: UNAuthorizationOptions? - environment.userNotificationClient.requestAuthorization = { options in - didRequestAuthrizationOptions = options - return Effect(value: true) + func testApplicationLaunchWithoutNotification() async throws { + let delegate = AsyncStream.streamWithContinuation() + let requestedAuthorizationOptions = ActorIsolated(nil) + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + store.dependencies.userNotifications.delegate = { delegate.stream } + store.dependencies.userNotifications.requestAuthorization = { options in + await requestedAuthorizationOptions.setValue(options) + return true } - - environment.userNotificationClient.delegate = { - didSubscribeNotifications = true - return delegateActionSubject.eraseToEffect() + let task = await store.send(.didFinishLaunching(notification: nil)) + await store.receive(.requestAuthorizationResponse(.success(true))) + await requestedAuthorizationOptions.withValue { + XCTAssertNoDifference($0, [.alert, .badge, .sound]) } - - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didFinishLaunching(notification: nil)), - .receive(.requestAuthorizationResponse(.success(true))), - .do { XCTAssertEqual(didRequestAuthrizationOptions, [.alert, .badge, .sound]) }, - .do { XCTAssertTrue(didSubscribeNotifications) }, - .do { delegateActionSubject.send(completion: .finished) } - ) + await task.cancel() } - func testApplicationLaunchWithtNotification() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } - - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didFinishLaunching(notification: .count(5))) { - $0.count = 5 - }, - .receive(.requestAuthorizationResponse(.success(true))), - .do { delegateActionSubject.send(completion: .finished) } + func testApplicationLaunchWithtNotification() async throws { + let delegate = AsyncStream.streamWithContinuation() + let requestedAuthorizationOptions = ActorIsolated(nil) + + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() ) + store.dependencies.userNotifications.delegate = { delegate.stream } + store.dependencies.userNotifications.requestAuthorization = { options in + await requestedAuthorizationOptions.setValue(options) + return true + } + + let task = await store.send(.didFinishLaunching(notification: .count(5))) { + $0.count = 5 + } + await store.receive(.requestAuthorizationResponse(.success(true))) + await requestedAuthorizationOptions.withValue { + XCTAssertNoDifference($0, [.alert, .badge, .sound]) + } + await task.cancel() } + func testNotificationPresentationHandling() async throws { + let delegate = AsyncStream.streamWithContinuation() - func testNotificationPresentationHandling() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + store.dependencies.userNotifications.requestAuthorization = { _ in true } + store.dependencies.userNotifications.delegate = { delegate.stream } - var presentationOptions: UNNotificationPresentationOptions? - let completion = { presentationOptions = $0 } + let task = await store.send(.didFinishLaunching(notification: nil)) + await store.receive(.requestAuthorizationResponse(.success(true))) + var notificationPresentationOptions: UNNotificationPresentationOptions? + let willPresentNotificationCompletionHandler = { notificationPresentationOptions = $0 } let content = UNMutableNotificationContent() content.userInfo = ["count": 5] let notification = Notification( @@ -83,27 +76,39 @@ class ExampleTests: XCTestCase { ) ) - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didFinishLaunching(notification: nil)), - .receive(.requestAuthorizationResponse(.success(true))), - .do { delegateActionSubject.send(.willPresentNotification(notification, completion: completion)) }, - .receive(.userNotification(.willPresentNotification(notification, completion: completion))), - .do { XCTAssertEqual(presentationOptions, [.list, .banner, .sound]) }, - .do { delegateActionSubject.send(completion: .finished) } + delegate.continuation.yield( + .willPresentNotification( + notification, + completionHandler: { willPresentNotificationCompletionHandler($0) } + ) + ) + await store.receive( + .userNotifications( + .willPresentNotification( + notification, + completionHandler: willPresentNotificationCompletionHandler + ) + ) ) + XCTAssertNoDifference(notificationPresentationOptions, [.list, .banner, .sound]) + await task.cancel() } - func testReceivedNotification() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } + func testReceivedNotification() async throws { + let delegate = AsyncStream.streamWithContinuation() + + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + store.dependencies.userNotifications.requestAuthorization = { _ in true } + store.dependencies.userNotifications.delegate = { delegate.stream } - var didComplete = false - let completion = { didComplete = true } + let task = await store.send(.didFinishLaunching(notification: nil)) + await store.receive(.requestAuthorizationResponse(.success(true))) + var didReceiveResponseCompletionHandlerCalled = false + let didReceiveResponseCompletionHandler = { didReceiveResponseCompletionHandlerCalled = true } let content = UNMutableNotificationContent() content.userInfo = ["count": 5] let response = Notification.Response.user( @@ -120,25 +125,29 @@ class ExampleTests: XCTestCase { ) ) - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didFinishLaunching(notification: nil)), - .receive(.requestAuthorizationResponse(.success(true))), - .do { delegateActionSubject.send(.didReceiveResponse(response, completion: completion)) }, - .receive(.userNotification(.didReceiveResponse(response, completion: completion))) { - $0.count = 5 - }, - .do { XCTAssertTrue(didComplete) }, - .do { delegateActionSubject.send(completion: .finished) } + delegate.continuation.yield( + .didReceiveResponse(response, completionHandler: { didReceiveResponseCompletionHandler() }) ) + await store.receive( + .userNotifications( + .didReceiveResponse( + response, + completionHandler: didReceiveResponseCompletionHandler + ) + ) + ) { + $0.count = 5 + } + XCTAssert(didReceiveResponseCompletionHandlerCalled) + await task.cancel() } - func testReceiveBackgroundNotification() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } + func testReceiveBackgroundNotification() async throws { + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + store.dependencies.remote.fetchRemoteCount = { 5 } var backgroundFetchResult: UIBackgroundFetchResult? let backgroundNotification = BackgroundNotification( @@ -147,24 +156,20 @@ class ExampleTests: XCTestCase { fetchCompletionHandler: { backgroundFetchResult = $0 } ) - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didReceiveBackgroundNotification(backgroundNotification)), - .receive(.remoteCountResponse(.success(5))) { - $0.count = 5 - }, - .do { XCTAssertEqual(backgroundFetchResult, .newData) }, - .do { delegateActionSubject.send(completion: .finished) } - ) + await store.send(.didReceiveBackgroundNotification(backgroundNotification)) + await store.receive(.remoteCountResponse(.success(5))) { + $0.count = 5 + } + XCTAssertNoDifference(backgroundFetchResult, .newData) } - func testReceiveBackgroundNotificationFailure() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } - environment.remoteClient.fetchRemoteCount = { Effect(error: RemoteClient.Error()) } + func testReceiveBackgroundNotificationFailure() async throws { + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + struct Error: Swift.Error, Equatable {} + store.dependencies.remote.fetchRemoteCount = { throw Error() } var backgroundFetchResult: UIBackgroundFetchResult? let backgroundNotification = BackgroundNotification( @@ -173,21 +178,16 @@ class ExampleTests: XCTestCase { fetchCompletionHandler: { backgroundFetchResult = $0 } ) - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didReceiveBackgroundNotification(backgroundNotification)), - .receive(.remoteCountResponse(.failure(RemoteClient.Error()))), - .do { XCTAssertEqual(backgroundFetchResult, .failed) }, - .do { delegateActionSubject.send(completion: .finished) } - ) + await store.send(.didReceiveBackgroundNotification(backgroundNotification)) + await store.receive(.remoteCountResponse(.failure(Error()))) + XCTAssertNoDifference(backgroundFetchResult, .failed) } - func testReceiveBackgroundNotificationWithoutContent() throws { - let delegateActionSubject = PassthroughSubject() - environment.userNotificationClient.delegate = { delegateActionSubject.eraseToEffect() } + func testReceiveBackgroundNotificationWithoutContent() async throws { + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) var backgroundFetchResult: UIBackgroundFetchResult? let backgroundNotification = BackgroundNotification( @@ -196,43 +196,39 @@ class ExampleTests: XCTestCase { fetchCompletionHandler: { backgroundFetchResult = $0 } ) - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.didReceiveBackgroundNotification(backgroundNotification)), - .do { XCTAssertEqual(backgroundFetchResult, .noData) }, - .do { delegateActionSubject.send(completion: .finished) } - ) + await store.send(.didReceiveBackgroundNotification(backgroundNotification)) + XCTAssertNoDifference(backgroundFetchResult, .noData) } - func testTappedScheduleButton() throws { - var notificationRequest: UNNotificationRequest? - environment.userNotificationClient.add = { request in - notificationRequest = request - return Effect(value: ()) + func testTappedScheduleButton() async throws { + let store = TestStore( + initialState: App.State(count: nil), + reducer: App() + ) + + let notificationRequest = ActorIsolated(nil) + let removedPendingIdentifiers = ActorIsolated<[String]?>(nil) + + store.dependencies.userNotifications.add = { request in + await notificationRequest.setValue(request) } - var removedPendingIdentifiers: [String]? - environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers = { identifiers in - removedPendingIdentifiers = identifiers - return .fireAndForget {} + store.dependencies.userNotifications.removePendingNotificationRequestsWithIdentifiers = { identifiers in + await removedPendingIdentifiers.setValue(identifiers) } - TestStore( - initialState: AppState(count: nil), - reducer: appReducer, - environment: environment - ).assert( - .send(.tappedScheduleButton), - .receive(.addNotificationResponse(.success(Unit()))), - .do { XCTAssertEqual(removedPendingIdentifiers, ["example_notification"]) }, - .do { XCTAssertEqual(notificationRequest?.content.title, "Example title") }, - .do { XCTAssertEqual(notificationRequest?.content.body, "Example body") }, - .do { XCTAssertTrue(notificationRequest?.trigger is UNTimeIntervalNotificationTrigger) }, - .do { XCTAssertEqual( - (notificationRequest?.trigger as? UNTimeIntervalNotificationTrigger)?.timeInterval, 5 - )} - ) + await store.send(.tappedScheduleButton) + await store.receive(.addNotificationResponse(.success(Unit()))) + await removedPendingIdentifiers.withValue { + XCTAssertNoDifference($0, ["example_notification"]) + } + await notificationRequest.withValue { + XCTAssertEqual($0?.content.title, "Example title") + XCTAssertEqual($0?.content.body, "Example body") + XCTAssertTrue($0?.trigger is UNTimeIntervalNotificationTrigger) + XCTAssertEqual( + ($0?.trigger as? UNTimeIntervalNotificationTrigger)?.timeInterval, + 5 + ) + } } } diff --git a/Package.swift b/Package.swift index dfc956f..fe858b8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -18,7 +18,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.8.0"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.45.0"), ], targets: [ .target( diff --git a/README.md b/README.md index 38eb148..d22dae6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Composable User Notifications is library that bridges [the Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) and [User Notifications](https://developer.apple.com/documentation/usernotifications). +This library is modelling it's dependency using [swift concurrency](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/swiftconcurrency) since version 0.3.0. + * [Example](#example) * [Basic usage](#basic-usage) * [Installation](#installation) @@ -10,46 +12,46 @@ Composable User Notifications is library that bridges [the Composable Architectu Check out the Example demo to see how [ComposableUserNotifications](./Examples/Example) can be used. ## Basic usage -To handle incoming user notification you can observe the `UNUserNotificationCenterDelegate` actions `UserNotificationClient.Action` of the `UserNotificationClient.delegate` effect. +To handle incoming user notification you can observe the `UNUserNotificationCenterDelegate` actions through `UserNotificationClient.DelegateAction` of the `UserNotificationClient.delegate`. ```swift import ComposableUserNotifications -enum AppAction { - case userNotification(UserNotificationClient.Action) - +struct App: ReducerProtocol { + enum Action { + case userNotification(UserNotificationClient.DelegateAction) // Your domain's other actions: - ... -} +... ``` -The `UserNotificationClient.Action` holds the actions + +The `UserNotificationClient.DelegateAction` holds the actions * for handling foreground notifications `willPresentNotification(_:completion)` -* to process the user's response to a delivered notification `didReceiveResponse(_:completion:)` +* to process the user's response to a delivered notification `didReceiveResponse(_:completionHandler:)` * to display the in-app notification settings `openSettingsForNotification(_:)` -The wrapper around apple's `UNUserNotificationCenter` `UserNotificationClient`, should be part of your applications environment. -```swift -struct AppEnvironment { - var userNotificationClient: UserNotificationClient - - // Your domain's other dependencies: - ... -} -``` +The wrapper around apple's `UNUserNotificationCenter` `UserNotificationClient`, is available on the `DependencyValues` and can be retrieved on using `@Dependency(\.userNotifications)`. -At some point you need to subscribe to `UserNotificationClient.Action` in order not to miss any `UNUserNotificationCenterDelegate` related actions. This can be done early after starting the application. +At some point you need to subscribe to `UserNotificationClient.DelegateAction` in order not to miss any `UNUserNotificationCenterDelegate` related actions. This can be done early after starting the application. ```swift -let appReducer = Reducer { state, action, environment in +func reduce(into state: inout State, action: Action) -> EffectTask { switch action { - case .didFinishLaunching: // or onAppear of your first View - return environment.userNotificationClient - .delegate() - .map(AppAction.userNotification) + case let .didFinishLaunching(notification): + ... + return .run { send in + for await event in self.userNotifications.delegate() { + await send(.userNotifications(event)) + } + } + } + } +} ``` + When subscribing to these actions we can handle them as follows. ```swift +... case let .userNotification(.willPresentNotification(notification, completion)): return .fireAndForget { completion([.list, .banner, .sound]) @@ -62,20 +64,25 @@ When subscribing to these actions we can handle them as follows. case .userNotification(.openSettingsForNotification): return .none +... ``` To request authorization from the user you can use `requestAuthorization` and handle the users choice as a new action. ```swift -let appReducer = Reducer { state, action, environment in +func reduce(into state: inout State, action: Action) -> EffectTask { switch action { case .didFinishLaunching: - return .merge( - ..., - environment.userNotificationClient.requestAuthorization([.alert, .badge, .sound]) - .catchToEffect() - .map(AppAction.requestAuthorizationResponse) + return .task { + .requestAuthorizationResponse( + TaskResult { + try await self.userNotifications.requestAuthorization([.alert, .badge, .sound]) + } ) + } + } + ... +} ``` Adding notification requests is also straight forward. It can be done using `UNNotificationRequest` in conjunction with `UserNotificationClient.add(_:)`. @@ -92,17 +99,19 @@ Adding notification requests is also straight forward. It can be done using `UNN trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) ) - return .concatenate( - environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"]) - .fireAndForget(), - environment.userNotificationClient.add(request) - .map(Unit.init) - .catchToEffect() - .map(AppAction.addNotificationResponse) - ) + return .task { + await self.userNotifications + .removePendingNotificationRequestsWithIdentifiers(["example_notification"]) + return await .addNotificationResponse( + TaskResult { + Unit(try await self.userNotifications.add(request)) + } + ) + } + ... ``` -There are of course a lot more wrapped API calls to `UNUserNotificationCenter` available. -The true power of this approach again lies in the testability of your notification logic. +All API calls to `UNUserNotificationCenter` are available through `UserNotificationClient`. +The true power of this approach lies in the testability of your notification logic. For more info around testability have a look at [ExampleTests.swift](./Examples/Example/ExampleTests/ExampleTests.swift). ## Installation diff --git a/Sources/ComposableUserNotifications/Interface.swift b/Sources/ComposableUserNotifications/Interface.swift index 5200ea1..1fc4e13 100644 --- a/Sources/ComposableUserNotifications/Interface.swift +++ b/Sources/ComposableUserNotifications/Interface.swift @@ -1,6 +1,7 @@ import CoreLocation import ComposableArchitecture import UserNotifications +import XCTestDynamicOverlay /// A wrapper around UserNotifications's `UNUserNotificationCenter` that exposes its functionality through /// effects and actions, making it easy to use with the Composable Architecture and easy to test. @@ -13,88 +14,56 @@ public struct UserNotificationClient { /// Actions that correspond to `UNUserNotificationCenterDelegate` methods. /// /// See `UNUserNotificationCenterDelegate` for more information. - public enum Action { + public enum DeletegateAction { case willPresentNotification( _ notification: Notification, - completion: (UNNotificationPresentationOptions) -> Void) + completionHandler: (UNNotificationPresentationOptions) -> Void) @available(tvOS, unavailable) - case didReceiveResponse(_ response: Notification.Response, completion: () -> Void) + case didReceiveResponse(_ response: Notification.Response, completionHandler: () -> Void) case openSettingsForNotification(_ notification: Notification?) } - public var add: (UNNotificationRequest) -> Effect = { _ in - _unimplemented("add") - } + public var add: @Sendable (UNNotificationRequest) async throws -> Void = + unimplemented("\(Self.self).add") @available(tvOS, unavailable) - public var getDeliveredNotifications: () -> Effect<[Notification], Never> = { - _unimplemented("getDeliveredNotifications") - } + public var deliveredNotifications: @Sendable () async -> [Notification] = unimplemented("\(Self.self).deliveredNotifications") @available(tvOS, unavailable) - public var getNotificationCategories: () -> Effect, Never> = { - _unimplemented("getNotificationCategories") - } + public var notificationCategories: () async -> Set = unimplemented("\(Self.self).deliveredNotifications") - public var getNotificationSettings: () -> Effect = { - _unimplemented("getNotificationSettings") - } + public var notificationSettings: () async -> Notification.Settings = unimplemented("\(Self.self).notificationSettings") - public var getPendingNotificationRequests: () -> Effect<[Notification.Request], Never> = { - _unimplemented("getPendingNotificationRequests") - } + public var pendingNotificationRequests: () async -> [Notification.Request] = unimplemented("\(Self.self).pendingNotificationRequests") @available(tvOS, unavailable) - public var removeAllDeliveredNotifications: () -> Effect = { - _unimplemented("removeAllDeliveredNotifications") - } + public var removeAllDeliveredNotifications: () async -> Void = unimplemented("\(Self.self).removeAllDeliveredNotifications") - public var removeAllPendingNotificationRequests: () -> Effect = { - _unimplemented("removeAllPendingNotificationRequests") - } + public var removeAllPendingNotificationRequests: () async -> Void = unimplemented("\(Self.self).removeAllPendingNotificationRequests") @available(tvOS, unavailable) - public var removeDeliveredNotificationsWithIdentifiers: ([String]) -> Effect = { _ in - _unimplemented("removeDeliveredNotificationsWithIdentifiers") - } + public var removeDeliveredNotificationsWithIdentifiers: ([String]) async -> Void = unimplemented("\(Self.self).removeDeliveredNotificationsWithIdentifiers") - public var removePendingNotificationRequestsWithIdentifiers: ([String]) -> Effect = { _ in - _unimplemented("removePendingNotificationRequestsWithIdentifiers") - } + public var removePendingNotificationRequestsWithIdentifiers: ([String]) async -> Void = unimplemented("\(Self.self).removePendingNotificationRequestsWithIdentifiers") - public var requestAuthorization: (UNAuthorizationOptions) -> Effect = { _ in - _unimplemented("requestAuthorization") - } + public var requestAuthorization: (UNAuthorizationOptions) async throws -> Bool = + unimplemented("\(Self.self).requestAuthorization") @available(tvOS, unavailable) - public var setNotificationCategories: (Set) -> Effect = { _ in - _unimplemented("setNotificationCategories") - } + public var setNotificationCategories: (Set) async -> Void = unimplemented("\(Self.self).setNotificationCategories") - public var supportsContentExtensions: () -> Bool = { - _unimplemented("supportsContentExtensions") - } + public var supportsContentExtensions: () -> Bool = unimplemented("\(Self.self).supportsContentExtensions") /// This Effect represents calls to the `UNUserNotificationCenterDelegate`. /// Handling the completion handlers of the `UNUserNotificationCenterDelegate`s methods /// by multiple observers might lead to unexpected behaviour. - public var delegate: () -> Effect = { - _unimplemented("delegate") - } - - public struct Error: Swift.Error, Equatable { - public let error: NSError - - public init(_ error: Swift.Error) { - self.error = error as NSError - } - } + public var delegate: @Sendable () -> AsyncStream = unimplemented("\(Self.self).delegate", placeholder: .finished) } -extension UserNotificationClient.Action: Equatable { - public static func == (lhs: UserNotificationClient.Action, rhs: UserNotificationClient.Action) -> Bool { +extension UserNotificationClient.DeletegateAction: Equatable { + public static func == (lhs: UserNotificationClient.DeletegateAction, rhs: UserNotificationClient.DeletegateAction) -> Bool { switch (lhs, rhs) { case let (.willPresentNotification(lhs, _), .willPresentNotification(rhs, _)): return lhs == rhs diff --git a/Sources/ComposableUserNotifications/Live.swift b/Sources/ComposableUserNotifications/Live.swift deleted file mode 100644 index ba1d7eb..0000000 --- a/Sources/ComposableUserNotifications/Live.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Foundation -import Combine -import ComposableArchitecture -import UserNotifications - -extension UserNotificationClient { - public static var live: UserNotificationClient { - let center = UNUserNotificationCenter.current() - - var client = UserNotificationClient() - client.add = { request in - .future { callback in - center.add(request) { error in - if let error = error { - callback(.failure(Error(error))) - } else { - callback(.success(())) - } - } - } - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - client.getDeliveredNotifications = { - .future { callback in - center.getDeliveredNotifications { notifications in - callback(.success(notifications.map(Notification.init(rawValue:)))) - } - } - } - #endif - - client.getNotificationSettings = { - Effect.future { callback in - center.getNotificationSettings { settings in - callback(.success(Notification.Settings(rawValue: settings))) - } - }.eraseToEffect() - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - client.getNotificationCategories = { - Effect.future { callback in - center.getNotificationCategories { categories in - callback(.success(categories)) - } - } - } - #endif - - client.getPendingNotificationRequests = { - Effect.future { callback in - center.getPendingNotificationRequests { requests in - callback(.success(requests.map(Notification.Request.init(rawValue:)))) - } - } - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - client.removeAllDeliveredNotifications = { - .fireAndForget { - center.removeAllDeliveredNotifications() - } - } - #endif - - client.removeAllPendingNotificationRequests = { - .fireAndForget { - center.removeAllPendingNotificationRequests() - } - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - client.removeDeliveredNotificationsWithIdentifiers = { identifiers in - .fireAndForget { - center.removeDeliveredNotifications(withIdentifiers: identifiers) - } - } - #endif - - client.removePendingNotificationRequestsWithIdentifiers = { identifiers in - .fireAndForget { - center.removePendingNotificationRequests(withIdentifiers: identifiers) - } - } - - client.requestAuthorization = { options in - .future { callback in - center.requestAuthorization(options: options) { (granted, error) in - if let error = error { - callback(.failure(Error(error))) - } else { - callback(.success(granted)) - } - } - } - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - client.setNotificationCategories = { categories in - .fireAndForget { - center.setNotificationCategories(categories) - } - } - #endif - - client.supportsContentExtensions = { - center.supportsContentExtensions - } - - client.delegate = { - Effect.run { subscriber in - var delegate: Optional = Delegate(subscriber: subscriber) - UNUserNotificationCenter.current().delegate = delegate - return AnyCancellable { - delegate = nil - } - } - .share() - .eraseToEffect() - } - - return client - } -} - -private extension UserNotificationClient { - class Delegate: NSObject, UNUserNotificationCenterDelegate { - let subscriber: Effect.Subscriber - - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber - } - - func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - - subscriber.send( - .willPresentNotification( - Notification(rawValue: notification), - completion: completionHandler) - ) - } - - #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) - func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) { - - let wrappedResponse = Notification.Response(rawValue: response) - subscriber.send(.didReceiveResponse(wrappedResponse, completion: completionHandler)) - } - #endif - - #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) - func userNotificationCenter(_ center: UNUserNotificationCenter, - openSettingsFor notification: UNNotification?) { - - - let mappedNotification = notification.map(Notification.init) - subscriber.send(.openSettingsForNotification(mappedNotification)) - } - #endif - } -} diff --git a/Sources/ComposableUserNotifications/LiveKey.swift b/Sources/ComposableUserNotifications/LiveKey.swift new file mode 100644 index 0000000..17fb01d --- /dev/null +++ b/Sources/ComposableUserNotifications/LiveKey.swift @@ -0,0 +1,130 @@ +import Foundation +import ComposableArchitecture +import UserNotifications + +extension UserNotificationClient: DependencyKey { + public static var liveValue: Self { + let center = UNUserNotificationCenter.current() + + var client = UserNotificationClient() + client.add = { try await UNUserNotificationCenter.current().add($0) } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + client.deliveredNotifications = { + let notifications = await center.deliveredNotifications() + return notifications.map(Notification.init(rawValue:)) + } + #endif + + client.notificationSettings = { + let settings = await center.notificationSettings() + return Notification.Settings(rawValue: settings) + } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + client.notificationCategories = { + await center.notificationCategories() + } + #endif + + client.pendingNotificationRequests = { + let requests = await center.pendingNotificationRequests() + return requests.map(Notification.Request.init(rawValue:)) + } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + client.removeAllDeliveredNotifications = { + center.removeAllDeliveredNotifications() + } + #endif + + client.removeAllPendingNotificationRequests = { + center.removeAllPendingNotificationRequests() + } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + client.removeDeliveredNotificationsWithIdentifiers = { + center.removeDeliveredNotifications(withIdentifiers: $0) + } + #endif + + client.removePendingNotificationRequestsWithIdentifiers = { + center.removePendingNotificationRequests(withIdentifiers: $0) + } + + client.requestAuthorization = { + try await center.requestAuthorization(options: $0) + } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + client.setNotificationCategories = { + center.setNotificationCategories($0) + } + #endif + + client.supportsContentExtensions = { + center.supportsContentExtensions + } + + client.delegate = { + AsyncStream { continuation in + let delegate = Delegate(continuation: continuation) + UNUserNotificationCenter.current().delegate = delegate + continuation.onTermination = { _ in + let _ = delegate + } + } + } + + return client + } +} + +private extension UserNotificationClient { + class Delegate: NSObject, UNUserNotificationCenterDelegate { + let continuation: AsyncStream.Continuation + + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + self.continuation.yield( + .willPresentNotification( + Notification(rawValue: notification), + completionHandler: completionHandler + ) + ) + } + + #if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let wrappedResponse = Notification.Response(rawValue: response) + self.continuation.yield( + .didReceiveResponse(wrappedResponse) { completionHandler() } + ) + } + #endif + + #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) + func userNotificationCenter( + _ center: UNUserNotificationCenter, + openSettingsFor notification: UNNotification? + ) { + let mappedNotification = notification.map(Notification.init) + self.continuation.yield( + .openSettingsForNotification(mappedNotification) + ) + } + #endif + } +} diff --git a/Sources/ComposableUserNotifications/Mock.swift b/Sources/ComposableUserNotifications/Mock.swift deleted file mode 100644 index 9818e66..0000000 --- a/Sources/ComposableUserNotifications/Mock.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import ComposableArchitecture -import UserNotifications - -extension UserNotificationClient { - @available(tvOS, unavailable) - public static func mock( - add: @escaping (UNNotificationRequest) -> Effect = { _ in - _unimplemented("add") - }, - getDeliveredNotifications: @escaping () -> Effect<[Notification], Never> = { - _unimplemented("getDeliveredNotifications") - }, - getNotificationCategories: @escaping () -> Effect, Never> = { - _unimplemented("getNotificationCategories") - }, - getNotificationSettings: @escaping () -> Effect = { - _unimplemented("getNotificationSettings") - }, - getPendingNotificationRequests: @escaping () -> Effect<[Notification.Request], Never> = { - _unimplemented("getPendingNotificationRequests") - }, - removeAllDeliveredNotifications: @escaping () -> Effect = { - _unimplemented("removeAllDeliveredNotifications") - }, - removeAllPendingNotificationRequests: @escaping () -> Effect = { - _unimplemented("removeAllPendingNotificationRequests") - }, - removeDeliveredNotificationsWithIdentifiers: @escaping ([String]) -> Effect = { _ in - _unimplemented("removeDeliveredNotificationsWithIdentifiers") - }, - removePendingNotificationRequestsWithIdentifiers: @escaping ([String]) -> Effect = { _ in - _unimplemented("removePendingNotificationRequestsWithIdentifiers") - }, - requestAuthorization: @escaping (UNAuthorizationOptions) -> Effect = { _ in - _unimplemented("requestAuthorization") - }, - setNotificationCategories: @escaping (Set) -> Effect = { _ in - _unimplemented("setNotificationCategories") - }, - supportsContentExtensions: @escaping () -> Bool = { - _unimplemented("setNotificationCategories") - }, - delegate: @escaping () -> Effect = { - _unimplemented("getDeliveredNotifications") - } - ) -> Self { - Self( - add: add, - getDeliveredNotifications: getDeliveredNotifications, - getNotificationCategories: getNotificationCategories, - getNotificationSettings: getNotificationSettings, - getPendingNotificationRequests: getPendingNotificationRequests, - removeAllDeliveredNotifications: removeAllDeliveredNotifications, - removeAllPendingNotificationRequests: removeAllPendingNotificationRequests, - removeDeliveredNotificationsWithIdentifiers: removeDeliveredNotificationsWithIdentifiers, - removePendingNotificationRequestsWithIdentifiers: removePendingNotificationRequestsWithIdentifiers, - requestAuthorization: requestAuthorization, - setNotificationCategories: setNotificationCategories, - supportsContentExtensions: supportsContentExtensions, - delegate: delegate - ) - } - - @available(iOS, unavailable) - @available(watchOS, unavailable) - @available(macOS, unavailable) - @available(macCatalyst, unavailable) - public static func mock( - add: @escaping (UNNotificationRequest) -> Effect = { _ in - _unimplemented("add") - }, - getNotificationSettings: @escaping () -> Effect = { - _unimplemented("getNotificationSettings") - }, - getPendingNotificationRequests: @escaping () -> Effect<[Notification.Request], Never> = { - _unimplemented("getPendingNotificationRequests") - }, - removeAllPendingNotificationRequests: @escaping () -> Effect = { - _unimplemented("removeAllPendingNotificationRequests") - }, - removePendingNotificationRequestsWithIdentifiers: @escaping ([String]) -> Effect = { _ in - _unimplemented("removePendingNotificationRequestsWithIdentifiers") - }, - requestAuthorization: @escaping (UNAuthorizationOptions) -> Effect = { _ in - _unimplemented("requestAuthorization") - }, - supportsContentExtensions: @escaping () -> Bool = { - _unimplemented("setNotificationCategories") - }, - delegate: @escaping () -> Effect = { - _unimplemented("getDeliveredNotifications") - } - ) -> Self { - Self( - add: add, - getNotificationSettings: getNotificationSettings, - getPendingNotificationRequests: getPendingNotificationRequests, - removeAllPendingNotificationRequests: removeAllPendingNotificationRequests, - removePendingNotificationRequestsWithIdentifiers: removePendingNotificationRequestsWithIdentifiers, - requestAuthorization: requestAuthorization, - supportsContentExtensions: supportsContentExtensions, - delegate: delegate - ) - } -} - -public func _unimplemented( - _ function: StaticString, file: StaticString = #file, line: UInt = #line -) -> Never { - fatalError( - """ - `\(function)` was called but is not implemented. Be sure to provide an implementation for - this endpoint when creating the mock. - """, - file: file, - line: line - ) -} diff --git a/Sources/ComposableUserNotifications/Model.swift b/Sources/ComposableUserNotifications/Model.swift index 682fb18..a4293ec 100644 --- a/Sources/ComposableUserNotifications/Model.swift +++ b/Sources/ComposableUserNotifications/Model.swift @@ -1,5 +1,6 @@ import UserNotifications import CoreLocation +import XCTestDynamicOverlay public struct Notification: Equatable { public let rawValue: UNNotification? @@ -77,70 +78,44 @@ extension Notification { public var rawValue: UNNotificationContent? @available(tvOS, unavailable) - public var title: () -> String = { - _unimplemented("title") - } + public var title: () -> String = unimplemented("title") @available(tvOS, unavailable) - public var subtitle: () -> String = { - _unimplemented("subtitle") - } + public var subtitle: () -> String = unimplemented("subtitle") @available(tvOS, unavailable) - public var body: () -> String = { - _unimplemented("body") - } + public var body: () -> String = unimplemented("body") - public var badge: () -> NSNumber? = { - _unimplemented("badge") - } + public var badge: () -> NSNumber? = unimplemented("badge") @available(tvOS, unavailable) - public var sound: () -> UNNotificationSound? = { - _unimplemented("sound") - } + public var sound: () -> UNNotificationSound? = unimplemented("sound") @available(macOS, unavailable) @available(tvOS, unavailable) - public var launchImageName: () -> String = { - _unimplemented("launchImageName") - } + public var launchImageName: () -> String = unimplemented("launchImageName") @available(tvOS, unavailable) - public var userInfo: () -> [AnyHashable : Any] = { - _unimplemented("userInfo") - } + public var userInfo: () -> [AnyHashable: Any] = unimplemented("userInfo") @available(tvOS, unavailable) - public var attachments: () -> [Notification.Attachment] = { - _unimplemented("attachments") - } + public var attachments: () -> [Notification.Attachment] = unimplemented("attachments") @available(tvOS, unavailable) @available(watchOS, unavailable) - public var summaryArgument: () -> String = { - _unimplemented("summaryArgument") - } + public var summaryArgument: () -> String = unimplemented("summaryArgument") @available(tvOS, unavailable) @available(watchOS, unavailable) - public var summaryArgumentCount: () -> Int = { - _unimplemented("summaryArgumentCount") - } + public var summaryArgumentCount: () -> Int = unimplemented("summaryArgumentCount") @available(tvOS, unavailable) - public var categoryIdentifier: () -> String = { - _unimplemented("categoryIdentifier") - } + public var categoryIdentifier: () -> String = unimplemented("categoryIdentifier") @available(tvOS, unavailable) - public var threadIdentifier: () -> String = { - _unimplemented("threadIdentifier") - } + public var threadIdentifier: () -> String = unimplemented("threadIdentifier") - public var targetContentIdentifier: () -> String? = { - _unimplemented("targetContentIdentifier") - } + public var targetContentIdentifier: () -> String? = unimplemented("targetContentIdentifier") public init(rawValue: UNNotificationContent) { self.rawValue = rawValue @@ -460,75 +435,61 @@ extension Notification.Response { } extension Notification { - public struct Settings { - public var rawValue: () -> UNNotificationSettings? = { - _unimplemented("rawValue") - } + public struct Settings: Equatable { + public var rawValue: () -> UNNotificationSettings? = unimplemented("rawValue") @available(tvOS, unavailable) - public var alertSetting: () -> UNNotificationSetting = { - _unimplemented("alertSetting") - } + public var alertSetting: () -> UNNotificationSetting = unimplemented("alertSetting") @available(tvOS, unavailable) @available(watchOS, unavailable) - public var alertStyle: () -> UNAlertStyle = { - _unimplemented("alertStyle") - } + public var alertStyle: () -> UNAlertStyle = unimplemented("alertStyle") @available(macOS, unavailable) @available(tvOS, unavailable) - public var announcementSetting: () -> UNNotificationSetting = { - _unimplemented("announcementSetting") - } + public var announcementSetting: () -> UNNotificationSetting = unimplemented( + "announcementSetting" + ) - public var authorizationStatus: () -> UNAuthorizationStatus = { - _unimplemented("authorizationStatus") - } + public var authorizationStatus: () -> UNAuthorizationStatus = unimplemented( + "authorizationStatus" + ) @available(watchOS, unavailable) - public var badgeSetting: () -> UNNotificationSetting = { - _unimplemented("badgeSetting") - } + public var badgeSetting: () -> UNNotificationSetting = unimplemented("badgeSetting") @available(macOS, unavailable) @available(tvOS, unavailable) @available(watchOS, unavailable) - public var carPlaySetting: () -> UNNotificationSetting = { - _unimplemented("carPlaySetting") - } + public var carPlaySetting: () -> UNNotificationSetting = unimplemented("carPlaySetting") @available(tvOS, unavailable) - public var criticalAlertSetting: () -> UNNotificationSetting = { - _unimplemented("criticalAlertSetting") - } + public var criticalAlertSetting: () -> UNNotificationSetting = unimplemented( + "criticalAlertSetting" + ) @available(tvOS, unavailable) @available(watchOS, unavailable) - public var lockScreenSetting: () -> UNNotificationSetting = { - _unimplemented("lockScreenSetting") - } + public var lockScreenSetting: () -> UNNotificationSetting = unimplemented("lockScreenSetting") @available(tvOS, unavailable) - public var notificationCenterSetting: () -> UNNotificationSetting = { - _unimplemented("notificationCenterSetting") - } + public var notificationCenterSetting: () -> UNNotificationSetting = unimplemented( + "notificationCenterSetting" + ) @available(tvOS, unavailable) - public var providesAppNotificationSettings: () -> Bool = { - _unimplemented("providesAppNotificationSettings") - } + public var providesAppNotificationSettings: () -> Bool = unimplemented( + "providesAppNotificationSettings" + ) @available(tvOS, unavailable) @available(watchOS, unavailable) - public var showPreviewsSetting: () -> UNShowPreviewsSetting = { - _unimplemented("showPreviewsSetting") - } + public var showPreviewsSetting: () -> UNShowPreviewsSetting = unimplemented( + "showPreviewsSetting" + ) @available(tvOS, unavailable) - public var soundSetting: () -> UNNotificationSetting = { - _unimplemented("soundSetting") - } + public var soundSetting: () -> UNNotificationSetting = unimplemented("soundSetting") public init(rawValue: UNNotificationSettings) { self.rawValue = { rawValue } @@ -576,6 +537,10 @@ extension Notification { self.soundSetting = { rawValue.soundSetting } #endif } + + public static func == (lhs: Notification.Settings, rhs: Notification.Settings) -> Bool { + lhs.rawValue() == rhs.rawValue() + } } } diff --git a/Sources/ComposableUserNotifications/TestKey.swift b/Sources/ComposableUserNotifications/TestKey.swift new file mode 100644 index 0000000..0172db7 --- /dev/null +++ b/Sources/ComposableUserNotifications/TestKey.swift @@ -0,0 +1,87 @@ +import Foundation +import ComposableArchitecture +import UserNotifications +import XCTestDynamicOverlay + +extension DependencyValues { + public var userNotifications: UserNotificationClient { + get { self[UserNotificationClient.self] } + set { self[UserNotificationClient.self] = newValue } + } +} + +extension UserNotificationClient: TestDependencyKey { + public static let previewValue = Self.noop + +#if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + public static let testValue = Self( + add: unimplemented("\(Self.self).add"), + deliveredNotifications: unimplemented("\(Self.self).deliveredNotifications"), + notificationCategories: unimplemented("\(Self.self).notificationCategories", placeholder: []), + notificationSettings: unimplemented("\(Self.self).notificationSettings"), + pendingNotificationRequests: unimplemented("\(Self.self).pendingNotificationRequests"), + removeAllDeliveredNotifications: unimplemented("\(Self.self).removeAllDeliveredNotifications"), + removeAllPendingNotificationRequests: unimplemented( + "\(Self.self).removeAllPendingNotificationRequests" + ), + removeDeliveredNotificationsWithIdentifiers: unimplemented( + "\(Self.self).removeDeliveredNotificationsWithIdentifiers" + ), + removePendingNotificationRequestsWithIdentifiers: unimplemented( + "\(Self.self).removePendingNotificationRequestsWithIdentifiers" + ), + requestAuthorization: unimplemented("\(Self.self).requestAuthorization", placeholder: false), + setNotificationCategories: unimplemented("\(Self.self).setNotificationCategories"), + supportsContentExtensions: unimplemented( + "\(Self.self).supportsContentExtensions", placeholder: false + ), + delegate: unimplemented("\(Self.self).delegate", placeholder: .finished) + ) +#else // tvOS + public static let testValue = Self( + add: unimplemented("\(Self.self).add"), + deliveredNotifications: unimplemented("\(Self.self).deliveredNotifications"), + pendingNotificationRequests: unimplemented("\(Self.self).pendingNotificationRequests"), + removeAllPendingNotificationRequests: unimplemented( + "\(Self.self).removeAllPendingNotificationRequests" + ), + removePendingNotificationRequestsWithIdentifiers: unimplemented("\(Self.self).removePendingNotificationRequestsWithIdentifiers"), + requestAuthorization: unimplemented("\(Self.self).requestAuthorization", placeholder: false), + supportsContentExtensions: unimplemented( + "\(Self.self).supportsContentExtensions", placeholder: false + ), + delegate: unimplemented("\(Self.self).delegate", placeholder: .finished) + ) +#endif +} + +extension UserNotificationClient { +#if os(iOS) || os(macOS) || os(watchOS) || targetEnvironment(macCatalyst) + public static let noop = Self( + add: { _ in }, + deliveredNotifications: { [] }, + notificationCategories: { [] }, + notificationSettings: unimplemented("\(Self.self).notificationSettings"), + pendingNotificationRequests: { [] }, + removeAllDeliveredNotifications: { }, + removeAllPendingNotificationRequests: { }, + removeDeliveredNotificationsWithIdentifiers: { _ in }, + removePendingNotificationRequestsWithIdentifiers: { _ in }, + requestAuthorization: { _ in false }, + setNotificationCategories: { _ in }, + supportsContentExtensions: { false }, + delegate: { AsyncStream { _ in } } + ) +#else // tvOS + public static let noop = Self( + add: { _ in }, + deliveredNotifications: { [] }, + pendingNotificationRequests: { [] }, + removeAllPendingNotificationRequests: { }, + removePendingNotificationRequestsWithIdentifiers: { _ in }, + requestAuthorization: { _ in false }, + supportsContentExtensions: { false }, + delegate: { AsyncStream { _ in } } + ) +#endif +}