Skip to content

Commit

Permalink
NotificationsClients
Browse files Browse the repository at this point in the history
  • Loading branch information
BucekJiri committed Oct 21, 2022
1 parent fde205d commit f6e696d
Show file tree
Hide file tree
Showing 16 changed files with 596 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NotificationsClients"
BuildableName = "NotificationsClients"
BlueprintName = "NotificationsClients"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "RemoteNotificationsClient"
BuildableName = "RemoteNotificationsClient"
BlueprintName = "RemoteNotificationsClient"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UserNotificationsClient"
BuildableName = "UserNotificationsClient"
BlueprintName = "UserNotificationsClient"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NotificationsClients"
BuildableName = "NotificationsClients"
BlueprintName = "NotificationsClients"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
14 changes: 14 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "30314f1ece684dd60679d598a9b89107557b67d9",
"version" : "0.4.1"
}
}
],
"version" : 2
}
19 changes: 11 additions & 8 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@
import PackageDescription

let package = Package(
name: "NotificationsClient",
name: "NotificationsClients",
platforms: [.iOS(.v13)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "NotificationsClient",
targets: ["NotificationsClient"]),
name: "NotificationsClients",
targets: ["RemoteNotificationsClient",
"UserNotificationsClient"]),
],
dependencies: [
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.2.1"),
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "NotificationsClient",
dependencies: []),
.testTarget(
name: "NotificationsClientTests",
dependencies: ["NotificationsClient"]),
name: "RemoteNotificationsClient",
dependencies: [.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")]),
.target(
name: "UserNotificationsClient",
dependencies: [.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")]),
]
)
147 changes: 145 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,146 @@
# NotificationsClient
# ✉️️ NotificationsClients
![Swift Package Manager](https://img.shields.io/badge/Swift_Package_Manager-compatible-orange) ![Platforms](https://img.shields.io/badge/platforms-iOS%20-lightgrey.svg)

A set of dependencies to easily implement remote or local notifications in your Swift app.

### Dependencies included
Two dependencies included:
- **UserNotificationsClient** - Manages notification-related activities for your app. Basically just `UNUserNotificationCenter` and its delegate transformed into a testable async dependency.

- **RemoteNotificationsClient** - Handles app registration for remote notifications. `UIApplication`'s remote notifications API transformed into a testable async dependency.

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.

### Dependency format
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.
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.

## 📝 Requirements

iOS 13
Swift 5.7

## 📦 Installation

### Swift Package Manager
Copy this repository URL, and add the repo into your Package Dependencies:
```
https://github.com/nodes-ios/notificationsClients
```

## 💻 Usage

### Integrating the clients in your dependencies / environment
For production code, just use the `.live()` static property.

❗️ IMPORTANT❗️
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.
```swift
import UserNotificationsClient
import RemoteNotificationsClient

func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Set up Dependencies
setupAppEnvironment()
return true
}

func setupAppEnvironment() {
let environment = AppEnvironment(remoteNotificationsClient: RemoteNotificationsClient.live(),
userNotificationsClient: UserNotificationsClient.live())
}
```

### Requesting the push notifications authorization
```swift
func requestPushAuthorization() async {
do {
// Get the authorization status
switch await environment.userNotificationsClient.getAuthorizationStatus() {
case .notDetermined:
// Request the authorization
let allowed = try await environment.userNotificationsClient.requestAuthorization([.alert, .badge, .sound])
if allowed {
// Register the app to receive remote notifications
// You probably want to call this also on app start
// for case when the user allows the push permissions in the iOS settings
environment.remoteNotificationsClient.registerForRemoteNotifications()
}
default: return
}
} catch {
print("❗️ Could not request remote notifications authorization: \(error)")
}
}
```

### Receiving remote notifications
Listen to the async stream of notification events.
```swift
// Listen to incoming notification events
// Call this in the app delegate preferably
Task {
for await event in environment.userNotificationsClient.delegate() {
handleNotificationEvent(event)
}
}

func handleNotificationEvent(_ event: UserNotificationClient.DelegateEvent) {
switch event {
case .willPresentNotification(_, let completion):
// Notification presented when the app is in foregroud
// Decide how to present it depending on the context
// Pass the presentation options to the completion
// If you do not pass anythig, the notification will not be presented
completion([.banner, .sound, .list, .badge])
case .didReceiveResponse(let response, let completion):
// User tapped on the notification
// Is triggered both when the app is in foreground and background
handleNotificationResponse(response)
completion()
case .openSettingsForNotification:
return
}
}
```
### Testing
See the `Testing` DocC article inside the targets.


## Why the heck is Firebase not integrated in this already? 🔥
Two reasons:
1. `FirebaseMessaging` works unreliably outside the app's main target. You really want to set it up inside your app delegate.
2. This is a generic implementation that should work regardless the push notifications source.

If you want to quickly integrate `FirebaseMessaging` with this, is most cases you just need to:
- Configure Firebase
- Assign the Messaging delegate
- Send the Firebase token to your backend

```swift
import Firebase
import FirebaseMessaging

extension AppDelegate: MessagingDelegate {
func startFirebase() {
FirebaseApp.configure()
Messaging.messaging().delegate = self
}

func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
// Send the Firebase token to your backend here
}
}
```









A description of this package.
6 changes: 0 additions & 6 deletions Sources/NotificationsClient/NotificationsClient.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# ``RemoteNotificationsClient``

Client that can be used as a dependency to handle remote notification registation tasks.

## Overview

Basically just `UIApplication` remote notifications related APIs transformed into a dependency.
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.

## Topics

### Essentials

- <doc:Testing>

### Subtopic
26 changes: 26 additions & 0 deletions Sources/RemoteNotificationsClient/Documentation.docc/Testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Testing

## Preparing the dependency for a test

The main advantage of using this dependency structure is the simplicity of overriding its properties. This is very useful for testing.

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.

Simple example:

```swift
func sillyTestExample async {
var didRegister = false
var client = RemoteNotificationsClient.failing
client.registerForRemoteNotifications = {
didRegister = true
}

await viewModel.userTappedRegisterNotifications()
// Calling any other closure on the client apart from the one overriden will make the test fail here
XCAssertTrue(didRegister)
}
```



21 changes: 21 additions & 0 deletions Sources/RemoteNotificationsClient/Interface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// RemoteNotificationsClient.swift
//
//
// Created by Jiří Buček on 21.09.2022.
//

import Foundation

/// Interface for a client that handles app registration for remote notifications
public struct RemoteNotificationsClient {

/// A Boolean value that indicates whether the app is currently registered for remote notifications.
public var isRegisteredForRemoteNotifications: () -> Bool

/// Register the app to receive remote notifications
public var registerForRemoteNotifications: () -> Void

/// Unregister the app to receive remote notifications
public var unregisterForRemoteNotifications: () async -> Void
}
19 changes: 19 additions & 0 deletions Sources/RemoteNotificationsClient/Live.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// File.swift
//
//
// Created by Jiří Buček on 21.09.2022.
//

import UIKit

extension RemoteNotificationsClient {
/// Live implementation of the RemoteNotificationClient interface.
///
/// Transforms the remote notification related APIs of the `UIApplication` to the `RemoteNotificationsClient` interface
public static let live = Self(
isRegisteredForRemoteNotifications: { UIApplication.shared.isRegisteredForRemoteNotifications },
registerForRemoteNotifications: { UIApplication.shared.registerForRemoteNotifications() },
unregisterForRemoteNotifications: { await UIApplication.shared.unregisterForRemoteNotifications() }
)
}
Loading

0 comments on commit f6e696d

Please sign in to comment.