Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ APNS/2 is a go package designed for simple, flexible and fast Apple Push Notific
- Supports new Apple Token Based Authentication (JWT)
- Supports new iOS 10 features such as Collapse IDs, Subtitles and Mutable Notifications
- Supports new iOS 15 features interruptionLevel and relevanceScore
- Supports iOS 16 features for live-activity notifications
- Supports persistent connections to APNs
- Supports VoIP/PushKit notifications (iOS 8 and later)
- Modular & easy to use
Expand Down
12 changes: 12 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,18 @@ func TestPushTypeMDMHeader(t *testing.T) {
assert.NoError(t, err)
}

func TestPushTypeLiveActivityHeader(t *testing.T) {
notification := mockNotification()
notification.PushType = apns.PushTypeLiveActivity
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "liveactivity", r.Header.Get("apns-push-type"))
}))

defer server.Close()
_, err := mockClient(server.URL).Push(notification)
assert.NoError(t, err)
}

func TestAuthorizationHeader(t *testing.T) {
n := mockNotification()
token := mockToken()
Expand Down
5 changes: 5 additions & 0 deletions notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const (
// contact the MDM server. If you set this push type, you must use the topic
// from the UID attribute in the subject of your MDM push certificate.
PushTypeMDM EPushType = "mdm"

// PushTypeLiveActivity to signal changes to a live activity session.
// The liveactivity push type isn’t available on watchOS, macOS, and tvOS.
// It’s recommended on iOS and iPadOS.
PushTypeLiveActivity EPushType = "liveactivity"
)

const (
Expand Down
79 changes: 68 additions & 11 deletions payload/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,30 @@ const (
InterruptionLevelCritical EInterruptionLevel = "critical"
)

type D map[string]interface{}

// Payload represents a notification which holds the content that will be
// marshalled as JSON.
type Payload struct {
content map[string]interface{}
}

type aps struct {
Alert interface{} `json:"alert,omitempty"`
Badge interface{} `json:"badge,omitempty"`
Category string `json:"category,omitempty"`
ContentAvailable int `json:"content-available,omitempty"`
InterruptionLevel EInterruptionLevel `json:"interruption-level,omitempty"`
MutableContent int `json:"mutable-content,omitempty"`
RelevanceScore interface{} `json:"relevance-score,omitempty"`
Sound interface{} `json:"sound,omitempty"`
ThreadID string `json:"thread-id,omitempty"`
URLArgs []string `json:"url-args,omitempty"`
Alert interface{} `json:"alert,omitempty"`
Badge interface{} `json:"badge,omitempty"`
Category string `json:"category,omitempty"`
ContentAvailable int `json:"content-available,omitempty"`
InterruptionLevel EInterruptionLevel `json:"interruption-level,omitempty"`
MutableContent int `json:"mutable-content,omitempty"`
RelevanceScore interface{} `json:"relevance-score,omitempty"`
Sound interface{} `json:"sound,omitempty"`
ThreadID string `json:"thread-id,omitempty"`
URLArgs []string `json:"url-args,omitempty"`
StaleDate int64 `json:"stale-date,omitempty"`
DismissalDate int64 `json:"dismissal-date,omitempty"`
Event string `json:"event,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
ContentState map[string]interface{} `json:"content-state,omitempty"`
}

type alert struct {
Expand Down Expand Up @@ -218,7 +225,7 @@ func (p *Payload) AlertLaunchImage(image string) *Payload {
// specifiers in loc-key. See Localized Formatted Strings in Apple
// documentation for more information.
//
// {"aps":{"alert":{"loc-args":args}}}
// {"aps":{"alert":{"loc-args":args}}}
func (p *Payload) AlertLocArgs(args []string) *Payload {
p.aps().alert().LocArgs = args
return p
Expand Down Expand Up @@ -378,6 +385,56 @@ func (p *Payload) UnsetRelevanceScore() *Payload {
return p
}

// StaleDate defines the value stale-date for the aps payload
// The date when the system considers an update to the Live Activity to be out of date.
// ref: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
//
// {"aps":{"stale-date":1650998941}}
func (p *Payload) StaleDate(staleDate int64) *Payload {
p.aps().StaleDate = staleDate
return p
}

// DismissalDate defines the value dismissal-date for the aps payload
// The UNIX timestamp that represents the date at which the system ends a Live Activity and removes it from the Dynamic Island and the Lock Screen
// ref: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
//
// {"aps":{"dismissal-date":1650998945}}
func (p *Payload) DismissalDate(dismissalDate int64) *Payload {
p.aps().DismissalDate = dismissalDate
return p
}

// Events defines the value event for the aps payload
// Describes whether you update or end an ongoing Live Activity
// ref: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
//
// {"aps":{"event":"update"}}
func (p *Payload) Event(event string) *Payload {
p.aps().Event = event
return p
}

// Timestamp defines the value timestamp for the aps payload
// The UNIX timestamp that marks the time when you send the remote notification that updates or ends a Live Activity
// ref: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
//
// {"aps":{"timestamp":1168364460}}
func (p *Payload) Timestamp(value int64) *Payload {
p.aps().Timestamp = value
return p
}

// ContentState defines the value content-state for aps payload
// Describes and contains the dynamic content of a Live Activity.
// ref :https://developer.apple.com/documentation/activitykit/activity/contentstate-swift.typealias
//
// {"aps":{"content-state":{"product_id": 123456, "product_name": "nameTest", "product_quantity": 4, "delivery_time": 34}}}
func (p *Payload) ContentState(content map[string]interface{}) *Payload {
p.aps().ContentState = content
return p
}

// MarshalJSON returns the JSON encoded version of the Payload
func (p *Payload) MarshalJSON() ([]byte, error) {
return json.Marshal(p.content)
Expand Down
42 changes: 42 additions & 0 deletions payload/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,45 @@ func TestCombined(t *testing.T) {
b, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"alert":"hello","badge":1,"interruption-level":"active","relevance-score":0.1,"sound":"Default.caf"},"key":"val"}`, string(b))
}

func TestEvent(t *testing.T) {
payload := NewPayload().Event("update")
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"event":"update"}}`, string(data))
}

func TestStaleDate(t *testing.T) {
payload := NewPayload().StaleDate(12324243)
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"stale-date":12324243}}`, string(data))
}

func TestDismissalDate(t *testing.T) {
payload := NewPayload().DismissalDate(1689811132)
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"dismissal-date":1689811132}}`, string(data))
}

func TestTimestamp(t *testing.T) {
payload := NewPayload().Timestamp(1168364460)
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"timestamp":1168364460}}`, string(data))
}

func TestContentState(t *testing.T) {
payload := NewPayload().ContentState(D{"item_id": 3, "availability": 1, "volume": 4.5, "item_status": "ACCEPTED"})
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"content-state":{"availability":1,"item_id":3,"item_status":"ACCEPTED","volume":4.5}}}`, string(data))
}

func TestLiveActivityAttributes(t *testing.T) {
payload := NewPayload().Event("update").Timestamp(1168364460).StaleDate(12324243).ContentState(D{"item_id": 3, "availability": 1, "volume": 4.5, "item_status": "ACCEPTED"})
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"stale-date":12324243,"event":"update","timestamp":1168364460,"content-state":{"availability":1,"item_id":3,"item_status":"ACCEPTED","volume":4.5}}}`, string(data))
}

func TestLiveActivityAttributesMixedWithAlert(t *testing.T) {
payload := NewPayload().Alert("hello").Badge(1).Sound("Default.caf").InterruptionLevel(InterruptionLevelActive).RelevanceScore(0.1).Event("update").Timestamp(1168364460).StaleDate(12324243).ContentState(D{"item_id": 3, "availability": 1, "volume": 4.5, "item_status": "ACCEPTED"})
data, _ := json.Marshal(payload)
assert.Equal(t, `{"aps":{"alert":"hello","badge":1,"interruption-level":"active","relevance-score":0.1,"sound":"Default.caf","stale-date":12324243,"event":"update","timestamp":1168364460,"content-state":{"availability":1,"item_id":3,"item_status":"ACCEPTED","volume":4.5}}}`, string(data))
}