Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to start live activities #196

Merged
2 changes: 1 addition & 1 deletion Sources/APNSCore/Alert/APNSAlertNotificationContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//===----------------------------------------------------------------------===//

/// The information for displaying an alert.
public struct APNSAlertNotificationContent: Encodable, Sendable {
public struct APNSAlertNotificationContent: Encodable, Sendable, Hashable {
public struct StringValue: Encodable, Hashable, Sendable {
internal enum Configuration: Encodable, Hashable {
case raw(String)
Expand Down
62 changes: 48 additions & 14 deletions Sources/APNSCore/LiveActivity/APNSLiveActivityNotification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import struct Foundation.UUID
/// A live activity notification.
///
/// It is **important** that you do not encode anything with the key `aps`.
public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage {
public struct APNSLiveActivityNotification<ContentState: Encodable & Hashable & Sendable>:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't just add these requirements to the generic type. This is a breaking API change otherwise. We can make the conformance to those protocols conditional though and that should work.

APNSMessage
{
enum CodingKeys: CodingKey {
case aps
}
Expand All @@ -30,34 +32,62 @@ public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage
get {
return self.aps.timestamp
}

set {
self.aps.timestamp = newValue
}
}


public var alert: APNSAlertNotificationContent? {
get {
return self.aps.alert
}

set {
self.aps.alert = newValue
}
}

/// Event type e.g. update
public var event: APNSLiveActivityNotificationEvent {
public var event: any APNSLiveActivityNotificationEvent {
get {
return APNSLiveActivityNotificationEvent(rawValue: self.aps.event)
switch self.aps.event {
case "end":
return APNSLiveActivityNotificationEventEnd()
case "update":
return APNSLiveActivityNotificationEventUpdate()
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be exhaustive here and avoid using default cases if possible.

guard let attributesType = self.aps.attributesType,
let state = self.aps.attributesContent,
let alert = self.aps.alert
else {
// Default to update
return APNSLiveActivityNotificationEventUpdate()
}

return APNSLiveActivityNotificationEventStart(
attributes: .init(type: attributesType, state: state),
alert: alert
)
}
}

set {
self.aps.event = newValue.rawValue
}
}

/// The dynamic content of a Live Activity.
public var contentState: ContentState {
get {
return self.aps.contentState
}

set {
self.aps.contentState = newValue
}
}

public var dismissalDate: APNSLiveActivityDismissalDate? {
get {
return .init(dismissal: self.aps.dismissalDate)
Expand All @@ -66,7 +96,7 @@ public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage
self.aps.dismissalDate = newValue?.dismissal
}
}

/// A canonical UUID that identifies the notification. If there is an error sending the notification,
/// APNs uses this value to identify the notification to your server. The canonical form is 32 lowercase hexadecimal digits,
/// displayed in five groups separated by hyphens in the form 8-4-4-4-12. An example UUID is as follows:
Expand Down Expand Up @@ -108,7 +138,7 @@ public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage
priority: APNSPriority,
appID: String,
contentState: ContentState,
event: APNSLiveActivityNotificationEvent,
event: any APNSLiveActivityNotificationEvent,
timestamp: Int,
dismissalDate: APNSLiveActivityDismissalDate = .none,
apnsID: UUID? = nil
Expand All @@ -123,7 +153,6 @@ public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage
dismissalDate: dismissalDate
)
}


/// Initializes a new ``APNSLiveActivityNotification``.
///
Expand All @@ -146,13 +175,18 @@ public struct APNSLiveActivityNotification<ContentState: Encodable>: APNSMessage
topic: String,
apnsID: UUID? = nil,
contentState: ContentState,
event: APNSLiveActivityNotificationEvent,
event: any APNSLiveActivityNotificationEvent,
timestamp: Int,
dismissalDate: APNSLiveActivityDismissalDate = .none
) {
var attributes: APNSLiveActivityNotificationEventStart<ContentState>.Attributes?
if let event = event as? APNSLiveActivityNotificationEventStart<ContentState> {
attributes = event.attributes
}

self.aps = APNSLiveActivityNotificationAPSStorage(
timestamp: timestamp,
event: event.rawValue,
event: event,
contentState: contentState,
dismissalDate: dismissalDate.dismissal
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,42 @@
//
//===----------------------------------------------------------------------===//

struct APNSLiveActivityNotificationAPSStorage<ContentState: Encodable>: Encodable {
struct APNSLiveActivityNotificationAPSStorage<ContentState: Encodable & Hashable & Sendable>:
Encodable, Sendable, Hashable
{
enum CodingKeys: String, CodingKey {
case timestamp = "timestamp"
case event = "event"
case contentState = "content-state"
case dismissalDate = "dismissal-date"
case attributesType = "attributes-type"
case attributesContent = "attributes"
case alert = "alert"
}

var timestamp: Int
var event: String
var attributesType: String?
var attributesContent: ContentState?
var contentState: ContentState
var dismissalDate: Int?

var alert: APNSAlertNotificationContent?

init(
timestamp: Int,
event: String,
event: any APNSLiveActivityNotificationEvent,
contentState: ContentState,
dismissalDate: Int?
) {
self.timestamp = timestamp
self.event = event
self.contentState = contentState
self.dismissalDate = dismissalDate
self.event = event.rawValue

if let event = event as? APNSLiveActivityNotificationEventStart<ContentState> {
self.attributesType = event.attributes.type
self.attributesContent = event.attributes.state
self.alert = event.alert
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,65 @@
//
//===----------------------------------------------------------------------===//

public struct APNSLiveActivityNotificationEvent: Hashable {

/// The underlying raw value that is send to APNs.
@usableFromInline
internal let rawValue: String

/// Specifies that live activity should be updated
public static let update = Self(rawValue: "update")

/// Specifies that live activity should be ended
public static let end = Self(rawValue: "end")
public protocol APNSLiveActivityNotificationEvent: Hashable, Encodable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change and we can't just change from a struct to a protocol. We should try and continue to model it with the current struct type.

var rawValue: String { get }
}

public struct APNSLiveActivityNotificationEventUpdate: APNSLiveActivityNotificationEvent {
public let rawValue = "update"
}

public struct APNSLiveActivityNotificationEventEnd: APNSLiveActivityNotificationEvent {
public let rawValue = "end"
}

public protocol APNSLiveActivityNotificationEventStartStateProtocol: Encodable & Hashable & Sendable
{
associatedtype State: Encodable & Hashable & Sendable
}

public struct APNSLiveActivityNotificationEventStart<State: Encodable & Hashable & Sendable>:
APNSLiveActivityNotificationEvent, APNSLiveActivityNotificationEventStartStateProtocol
{
public struct Attributes: Encodable, Hashable, Sendable {
public let type: String
public let state: State

public init(type: String, state: State) {
self.type = type
self.state = state
}
}

public let rawValue = "start"
public let attributes: Attributes
public let alert: APNSAlertNotificationContent

public init(attributes: Attributes, alert: APNSAlertNotificationContent) {
self.attributes = attributes
self.alert = alert
}
}

extension APNSLiveActivityNotificationEvent where Self == APNSLiveActivityNotificationEventUpdate {
public static var update: APNSLiveActivityNotificationEventUpdate {
APNSLiveActivityNotificationEventUpdate()
}
}

extension APNSLiveActivityNotificationEvent where Self == APNSLiveActivityNotificationEventEnd {
public static var end: APNSLiveActivityNotificationEventEnd {
APNSLiveActivityNotificationEventEnd()
}
}

extension APNSLiveActivityNotificationEvent
where Self: APNSLiveActivityNotificationEventStartStateProtocol {
public static func start(type: String, state: State, alert: APNSAlertNotificationContent)
-> APNSLiveActivityNotificationEventStart<
State
>
{
.init(attributes: .init(type: type, state: state), alert: alert)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,77 @@ final class APNSLiveActivityNotificationTests: XCTestCase {
let string: String = "Test"
let number: Int = 123
}

func testEncodeUpdate() throws {
let notification = APNSLiveActivityNotification(
expiration: .immediately,
priority: .immediately,
appID: "test.app.id",
contentState: State(),
event: .update,
timestamp: 1672680658)

timestamp: 1_672_680_658)

let encoder = JSONEncoder()
let data = try encoder.encode(notification)

let expectedJSONString = """
{"aps":{"event":"update","content-state":{"string":"Test","number":123},"timestamp":1672680658}}
"""

let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary
let jsonObject2 =
try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!)
as! NSDictionary
XCTAssertEqual(jsonObject1, jsonObject2)
}

func testEncodeStart() throws {
let notification = APNSLiveActivityNotification(
expiration: .immediately,
priority: .immediately,
appID: "test.app.id",
contentState: State(),
// Need the fully qualified name here
event: APNSLiveActivityNotificationEventStart(
attributes: .init(type: "State", state: State()),
alert: .init(title: .raw("Update"))
),
timestamp: 1_672_680_658)

let encoder = JSONEncoder()
let data = try encoder.encode(notification)

let expectedJSONString = """
{"aps":{"event":"update","content-state":{"string":"Test","number":123},"timestamp":1672680658}}
"""
{"aps":{"event":"start", "alert": { "title": "Update" }, "attributes-type": "State", "attributes": {"string":"Test","number":123},"content-state":{"string":"Test","number":123},"timestamp":1672680658}}
"""

let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary
let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary
let jsonObject2 =
try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!)
as! NSDictionary
XCTAssertEqual(jsonObject1, jsonObject2)
}

func testEncodeEndNoDismiss() throws {
let notification = APNSLiveActivityNotification(
expiration: .immediately,
priority: .immediately,
appID: "test.app.id",
contentState: State(),
event: .end,
timestamp: 1672680658)
timestamp: 1_672_680_658)

let encoder = JSONEncoder()
let data = try encoder.encode(notification)

let expectedJSONString = """
{"aps":{"event":"end","content-state":{"string":"Test","number":123},"timestamp":1672680658}}
"""
{"aps":{"event":"end","content-state":{"string":"Test","number":123},"timestamp":1672680658}}
"""

let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary
let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary
let jsonObject2 =
try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!)
as! NSDictionary
XCTAssertEqual(jsonObject1, jsonObject2)
}

Expand All @@ -71,19 +102,21 @@ final class APNSLiveActivityNotificationTests: XCTestCase {
appID: "test.app.id",
contentState: State(),
event: .end,
timestamp: 1672680658,
dismissalDate: .timeIntervalSince1970InSeconds(1672680800))
timestamp: 1_672_680_658,
dismissalDate: .timeIntervalSince1970InSeconds(1_672_680_800))

let encoder = JSONEncoder()
let data = try encoder.encode(notification)

let expectedJSONString = """
{"aps":{"event":"end","content-state":{"string":"Test","number":123},"timestamp":1672680658,
"dismissal-date":1672680800}}
"""
{"aps":{"event":"end","content-state":{"string":"Test","number":123},"timestamp":1672680658,
"dismissal-date":1672680800}}
"""

let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary
let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary
let jsonObject2 =
try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!)
as! NSDictionary
XCTAssertEqual(jsonObject1, jsonObject2)
}
}