Skip to content

Commit f6e696d

Browse files
committed
NotificationsClients
1 parent fde205d commit f6e696d

File tree

16 files changed

+596
-27
lines changed

16 files changed

+596
-27
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1400"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "NotificationsClients"
18+
BuildableName = "NotificationsClients"
19+
BlueprintName = "NotificationsClients"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
<BuildActionEntry
24+
buildForTesting = "YES"
25+
buildForRunning = "YES"
26+
buildForProfiling = "YES"
27+
buildForArchiving = "YES"
28+
buildForAnalyzing = "YES">
29+
<BuildableReference
30+
BuildableIdentifier = "primary"
31+
BlueprintIdentifier = "RemoteNotificationsClient"
32+
BuildableName = "RemoteNotificationsClient"
33+
BlueprintName = "RemoteNotificationsClient"
34+
ReferencedContainer = "container:">
35+
</BuildableReference>
36+
</BuildActionEntry>
37+
<BuildActionEntry
38+
buildForTesting = "YES"
39+
buildForRunning = "YES"
40+
buildForProfiling = "YES"
41+
buildForArchiving = "YES"
42+
buildForAnalyzing = "YES">
43+
<BuildableReference
44+
BuildableIdentifier = "primary"
45+
BlueprintIdentifier = "UserNotificationsClient"
46+
BuildableName = "UserNotificationsClient"
47+
BlueprintName = "UserNotificationsClient"
48+
ReferencedContainer = "container:">
49+
</BuildableReference>
50+
</BuildActionEntry>
51+
</BuildActionEntries>
52+
</BuildAction>
53+
<TestAction
54+
buildConfiguration = "Debug"
55+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
56+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
57+
shouldUseLaunchSchemeArgsEnv = "YES">
58+
<Testables>
59+
</Testables>
60+
</TestAction>
61+
<LaunchAction
62+
buildConfiguration = "Debug"
63+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
64+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
65+
launchStyle = "0"
66+
useCustomWorkingDirectory = "NO"
67+
ignoresPersistentStateOnLaunch = "NO"
68+
debugDocumentVersioning = "YES"
69+
debugServiceExtension = "internal"
70+
allowLocationSimulation = "YES">
71+
</LaunchAction>
72+
<ProfileAction
73+
buildConfiguration = "Release"
74+
shouldUseLaunchSchemeArgsEnv = "YES"
75+
savedToolIdentifier = ""
76+
useCustomWorkingDirectory = "NO"
77+
debugDocumentVersioning = "YES">
78+
<MacroExpansion>
79+
<BuildableReference
80+
BuildableIdentifier = "primary"
81+
BlueprintIdentifier = "NotificationsClients"
82+
BuildableName = "NotificationsClients"
83+
BlueprintName = "NotificationsClients"
84+
ReferencedContainer = "container:">
85+
</BuildableReference>
86+
</MacroExpansion>
87+
</ProfileAction>
88+
<AnalyzeAction
89+
buildConfiguration = "Debug">
90+
</AnalyzeAction>
91+
<ArchiveAction
92+
buildConfiguration = "Release"
93+
revealArchiveInOrganizer = "YES">
94+
</ArchiveAction>
95+
</Scheme>

Package.resolved

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,28 @@
44
import PackageDescription
55

66
let package = Package(
7-
name: "NotificationsClient",
7+
name: "NotificationsClients",
8+
platforms: [.iOS(.v13)],
89
products: [
910
// Products define the executables and libraries a package produces, and make them visible to other packages.
1011
.library(
11-
name: "NotificationsClient",
12-
targets: ["NotificationsClient"]),
12+
name: "NotificationsClients",
13+
targets: ["RemoteNotificationsClient",
14+
"UserNotificationsClient"]),
1315
],
1416
dependencies: [
17+
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.2.1"),
1518
// Dependencies declare other packages that this package depends on.
1619
// .package(url: /* package url */, from: "1.0.0"),
1720
],
1821
targets: [
1922
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
2023
// Targets can depend on other targets in this package, and on products in packages this package depends on.
2124
.target(
22-
name: "NotificationsClient",
23-
dependencies: []),
24-
.testTarget(
25-
name: "NotificationsClientTests",
26-
dependencies: ["NotificationsClient"]),
25+
name: "RemoteNotificationsClient",
26+
dependencies: [.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")]),
27+
.target(
28+
name: "UserNotificationsClient",
29+
dependencies: [.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")]),
2730
]
2831
)

README.md

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,146 @@
1-
# NotificationsClient
1+
# ✉️️ NotificationsClients
2+
![Swift Package Manager](https://img.shields.io/badge/Swift_Package_Manager-compatible-orange) ![Platforms](https://img.shields.io/badge/platforms-iOS%20-lightgrey.svg)
3+
4+
A set of dependencies to easily implement remote or local notifications in your Swift app.
5+
6+
### Dependencies included
7+
Two dependencies included:
8+
- **UserNotificationsClient** - Manages notification-related activities for your app. Basically just `UNUserNotificationCenter` and its delegate transformed into a testable async dependency.
9+
10+
- **RemoteNotificationsClient** - Handles app registration for remote notifications. `UIApplication`'s remote notifications API transformed into a testable async dependency.
11+
12+
Every client has its own target inside this SPM package called `NotificationsClients`. These clients are usually used in conjunction when implementing the remote notifications, thus I put them inside one package.
13+
14+
### Dependency format
15+
Both dependencies use `structs` with `closure` properties instead of protocols to define their interface. This makes for easy mocking and testing. This format is inspired by [PointFree](https://www.pointfree.co/) composable architecture.
16+
The interface is `async await`. The `UNUserNotificationCenterDelegate` is transformed from a delegate pattern to an `AsyncStream` of delegate events which is much more readable in the target code.
17+
18+
## 📝 Requirements
19+
20+
iOS 13
21+
Swift 5.7
22+
23+
## 📦 Installation
24+
25+
### Swift Package Manager
26+
Copy this repository URL, and add the repo into your Package Dependencies:
27+
```
28+
https://github.com/nodes-ios/notificationsClients
29+
```
30+
31+
## 💻 Usage
32+
33+
### Integrating the clients in your dependencies / environment
34+
For production code, just use the `.live()` static property.
35+
36+
❗️ IMPORTANT❗️
37+
You must call the.`.live()` of `UserNotificationsClient` syncronously before the app delegate's `didFinishWithLaunching` returns. This makes sure the correct delegate is assigned to the `UNUserNotificationCenter.current()` and therefore the app can react to push notifications when opened from suspended state.
38+
```swift
39+
import UserNotificationsClient
40+
import RemoteNotificationsClient
41+
42+
func application(
43+
_ application: UIApplication,
44+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
45+
) -> Bool {
46+
// Set up Dependencies
47+
setupAppEnvironment()
48+
return true
49+
}
50+
51+
func setupAppEnvironment() {
52+
let environment = AppEnvironment(remoteNotificationsClient: RemoteNotificationsClient.live(),
53+
userNotificationsClient: UserNotificationsClient.live())
54+
}
55+
```
56+
57+
### Requesting the push notifications authorization
58+
```swift
59+
func requestPushAuthorization() async {
60+
do {
61+
// Get the authorization status
62+
switch await environment.userNotificationsClient.getAuthorizationStatus() {
63+
case .notDetermined:
64+
// Request the authorization
65+
let allowed = try await environment.userNotificationsClient.requestAuthorization([.alert, .badge, .sound])
66+
if allowed {
67+
// Register the app to receive remote notifications
68+
// You probably want to call this also on app start
69+
// for case when the user allows the push permissions in the iOS settings
70+
environment.remoteNotificationsClient.registerForRemoteNotifications()
71+
}
72+
default: return
73+
}
74+
} catch {
75+
print("❗️ Could not request remote notifications authorization: \(error)")
76+
}
77+
}
78+
```
79+
80+
### Receiving remote notifications
81+
Listen to the async stream of notification events.
82+
```swift
83+
// Listen to incoming notification events
84+
// Call this in the app delegate preferably
85+
Task {
86+
for await event in environment.userNotificationsClient.delegate() {
87+
handleNotificationEvent(event)
88+
}
89+
}
90+
91+
func handleNotificationEvent(_ event: UserNotificationClient.DelegateEvent) {
92+
switch event {
93+
case .willPresentNotification(_, let completion):
94+
// Notification presented when the app is in foregroud
95+
// Decide how to present it depending on the context
96+
// Pass the presentation options to the completion
97+
// If you do not pass anythig, the notification will not be presented
98+
completion([.banner, .sound, .list, .badge])
99+
case .didReceiveResponse(let response, let completion):
100+
// User tapped on the notification
101+
// Is triggered both when the app is in foreground and background
102+
handleNotificationResponse(response)
103+
completion()
104+
case .openSettingsForNotification:
105+
return
106+
}
107+
}
108+
```
109+
### Testing
110+
See the `Testing` DocC article inside the targets.
111+
112+
113+
## Why the heck is Firebase not integrated in this already? 🔥
114+
Two reasons:
115+
1. `FirebaseMessaging` works unreliably outside the app's main target. You really want to set it up inside your app delegate.
116+
2. This is a generic implementation that should work regardless the push notifications source.
117+
118+
If you want to quickly integrate `FirebaseMessaging` with this, is most cases you just need to:
119+
- Configure Firebase
120+
- Assign the Messaging delegate
121+
- Send the Firebase token to your backend
122+
123+
```swift
124+
import Firebase
125+
import FirebaseMessaging
126+
127+
extension AppDelegate: MessagingDelegate {
128+
func startFirebase() {
129+
FirebaseApp.configure()
130+
Messaging.messaging().delegate = self
131+
}
132+
133+
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
134+
// Send the Firebase token to your backend here
135+
}
136+
}
137+
```
138+
139+
140+
141+
142+
143+
144+
145+
2146

3-
A description of this package.

Sources/NotificationsClient/NotificationsClient.swift

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# ``RemoteNotificationsClient``
2+
3+
Client that can be used as a dependency to handle remote notification registation tasks.
4+
5+
## Overview
6+
7+
Basically just `UIApplication` remote notifications related APIs transformed into a dependency.
8+
This particular type of dependency makes use of `structs` and `closures` to define its interface instead of `protocols`. This mechanism allows to override the interface implementation easily for better mocking and testing.
9+
10+
## Topics
11+
12+
### Essentials
13+
14+
- <doc:Testing>
15+
16+
### Subtopic
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Testing
2+
3+
## Preparing the dependency for a test
4+
5+
The main advantage of using this dependency structure is the simplicity of overriding its properties. This is very useful for testing.
6+
7+
Usually you want to start with the ``RemoteNotificationsClient/failing`` object and override only those properties that you intend to use in the test. Calling any other properties will make the test fail, thus recognizing and unintended use of the dependency.
8+
9+
Simple example:
10+
11+
```swift
12+
func sillyTestExample async {
13+
var didRegister = false
14+
var client = RemoteNotificationsClient.failing
15+
client.registerForRemoteNotifications = {
16+
didRegister = true
17+
}
18+
19+
await viewModel.userTappedRegisterNotifications()
20+
// Calling any other closure on the client apart from the one overriden will make the test fail here
21+
XCAssertTrue(didRegister)
22+
}
23+
```
24+
25+
26+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// RemoteNotificationsClient.swift
3+
//
4+
//
5+
// Created by Jiří Buček on 21.09.2022.
6+
//
7+
8+
import Foundation
9+
10+
/// Interface for a client that handles app registration for remote notifications
11+
public struct RemoteNotificationsClient {
12+
13+
/// A Boolean value that indicates whether the app is currently registered for remote notifications.
14+
public var isRegisteredForRemoteNotifications: () -> Bool
15+
16+
/// Register the app to receive remote notifications
17+
public var registerForRemoteNotifications: () -> Void
18+
19+
/// Unregister the app to receive remote notifications
20+
public var unregisterForRemoteNotifications: () async -> Void
21+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// File.swift
3+
//
4+
//
5+
// Created by Jiří Buček on 21.09.2022.
6+
//
7+
8+
import UIKit
9+
10+
extension RemoteNotificationsClient {
11+
/// Live implementation of the RemoteNotificationClient interface.
12+
///
13+
/// Transforms the remote notification related APIs of the `UIApplication` to the `RemoteNotificationsClient` interface
14+
public static let live = Self(
15+
isRegisteredForRemoteNotifications: { UIApplication.shared.isRegisteredForRemoteNotifications },
16+
registerForRemoteNotifications: { UIApplication.shared.registerForRemoteNotifications() },
17+
unregisterForRemoteNotifications: { await UIApplication.shared.unregisterForRemoteNotifications() }
18+
)
19+
}

0 commit comments

Comments
 (0)