diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fa456ff5..7b92f4c5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - go: [1.16.x, 1.17.x, 1.18.x] + go: [1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x] steps: - uses: actions/checkout@v2 @@ -33,4 +33,4 @@ jobs: - name: Update coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: goveralls -coverprofile=covprofile -service=github + run: goveralls -ignore=apns2/main.go -coverprofile=covprofile -service=github diff --git a/certificate/certificate.go b/certificate/certificate.go index 6496720a..7ed6590d 100644 --- a/certificate/certificate.go +++ b/certificate/certificate.go @@ -8,7 +8,7 @@ import ( "crypto/x509" "encoding/pem" "errors" - "io/ioutil" + "os" "strings" "golang.org/x/crypto/pkcs12" @@ -29,7 +29,7 @@ var ( // Use "" as the password argument if the PKCS#12 certificate is not password // protected. func FromP12File(filename string, password string) (tls.Certificate, error) { - p12bytes, err := ioutil.ReadFile(filename) + p12bytes, err := os.ReadFile(filename) if err != nil { return tls.Certificate{}, err } @@ -62,7 +62,7 @@ func FromP12Bytes(bytes []byte, password string) (tls.Certificate, error) { // Use "" as the password argument if the PEM certificate is not password // protected. func FromPemFile(filename string, password string) (tls.Certificate, error) { - bytes, err := ioutil.ReadFile(filename) + bytes, err := os.ReadFile(filename) if err != nil { return tls.Certificate{}, err } diff --git a/certificate/certificate_test.go b/certificate/certificate_test.go index b04eeac1..c52b3073 100644 --- a/certificate/certificate_test.go +++ b/certificate/certificate_test.go @@ -3,7 +3,7 @@ package certificate_test import ( "crypto/tls" "errors" - "io/ioutil" + "os" "testing" "github.com/sideshow/apns2/certificate" @@ -19,7 +19,7 @@ func TestValidCertificateFromP12File(t *testing.T) { } func TestValidCertificateFromP12Bytes(t *testing.T) { - bytes, _ := ioutil.ReadFile("_fixtures/certificate-valid.p12") + bytes, _ := os.ReadFile("_fixtures/certificate-valid.p12") cer, err := certificate.FromP12Bytes(bytes, "") assert.NoError(t, err) assert.NotEqual(t, tls.Certificate{}, cer) @@ -52,7 +52,7 @@ func TestValidCertificateFromPemFile(t *testing.T) { } func TestValidCertificateFromPemBytes(t *testing.T) { - bytes, _ := ioutil.ReadFile("_fixtures/certificate-valid.pem") + bytes, _ := os.ReadFile("_fixtures/certificate-valid.pem") cer, err := certificate.FromPemBytes(bytes, "") assert.NoError(t, err) assert.NotEqual(t, tls.Certificate{}, cer) @@ -65,7 +65,7 @@ func TestValidCertificateFromPemFileWithPKCS8PrivateKey(t *testing.T) { } func TestValidCertificateFromPemBytesWithPKCS8PrivateKey(t *testing.T) { - bytes, _ := ioutil.ReadFile("_fixtures/certificate-valid-pkcs8.pem") + bytes, _ := os.ReadFile("_fixtures/certificate-valid-pkcs8.pem") cer, err := certificate.FromPemBytes(bytes, "") assert.NoError(t, err) assert.NotEqual(t, tls.Certificate{}, cer) diff --git a/client.go b/client.go index cd98dd42..832b4210 100644 --- a/client.go +++ b/client.go @@ -192,6 +192,7 @@ func (c *Client) PushWithContext(ctx Context, n *Notification) (*Response, error r := &Response{} r.StatusCode = response.StatusCode r.ApnsID = response.Header.Get("apns-id") + r.ApnsUniqueID = response.Header.Get("apns-unique-id") decoder := json.NewDecoder(response.Body) if err := decoder.Decode(r); err != nil && err != io.EOF { @@ -226,7 +227,7 @@ func setHeaders(r *http.Request, n *Notification) { if n.Priority > 0 { r.Header.Set("apns-priority", strconv.Itoa(n.Priority)) } - if !n.Expiration.IsZero() { + if n.Expiration.After(time.Unix(0, 0)) { r.Header.Set("apns-expiration", strconv.FormatInt(n.Expiration.Unix(), 10)) } if n.PushType != "" { @@ -234,5 +235,4 @@ func setHeaders(r *http.Request, n *Notification) { } else { r.Header.Set("apns-push-type", string(PushTypeAlert)) } - } diff --git a/client_test.go b/client_test.go index 83061ddb..9c83d781 100644 --- a/client_test.go +++ b/client_test.go @@ -8,7 +8,7 @@ import ( "crypto/tls" "errors" "fmt" - "io/ioutil" + "io" "net" "net/http" "net/http/httptest" @@ -245,6 +245,25 @@ func TestHeaders(t *testing.T) { assert.NoError(t, err) } +func TestExpirationHeader(t *testing.T) { + n := mockNotification() + n.ApnsID = "84DB694F-464F-49BD-960A-D6DB028335C9" + n.CollapseID = "game1.start.identifier" + n.Topic = "com.testapp" + n.Priority = 10 + n.Expiration = time.Unix(0, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, n.ApnsID, r.Header.Get("apns-id")) + assert.Equal(t, n.CollapseID, r.Header.Get("apns-collapse-id")) + assert.Equal(t, "10", r.Header.Get("apns-priority")) + assert.Equal(t, n.Topic, r.Header.Get("apns-topic")) + assert.Equal(t, "", r.Header.Get("apns-expiration")) + })) + defer server.Close() + _, err := mockClient(server.URL).Push(n) + assert.NoError(t, err) +} + func TestPushTypeAlertHeader(t *testing.T) { n := mockNotification() n.PushType = apns.PushTypeAlert @@ -322,6 +341,28 @@ func TestPushTypeMDMHeader(t *testing.T) { assert.NoError(t, err) } +func TestPushTypeLiveActivityHeader(t *testing.T) { + n := mockNotification() + n.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(n) + assert.NoError(t, err) +} + +func TestPushTypePushToTalkHeader(t *testing.T) { + n := mockNotification() + n.PushType = apns.PushTypePushToTalk + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "pushtotalk", r.Header.Get("apns-push-type")) + })) + defer server.Close() + _, err := mockClient(server.URL).Push(n) + assert.NoError(t, err) +} + func TestAuthorizationHeader(t *testing.T) { n := mockNotification() token := mockToken() @@ -340,7 +381,7 @@ func TestAuthorizationHeader(t *testing.T) { func TestPayload(t *testing.T) { n := mockNotification() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, n.Payload, body) })) @@ -359,9 +400,11 @@ func TestBadPayload(t *testing.T) { func Test200SuccessResponse(t *testing.T) { n := mockNotification() var apnsID = "02ABC856-EF8D-4E49-8F15-7B8A61D978D6" + var apnsUniqueID = "A6739D99-D92A-424B-A91E-BF012365BD4E" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("apns-id", apnsID) + w.Header().Set("apns-unique-id", apnsUniqueID) w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -369,6 +412,7 @@ func Test200SuccessResponse(t *testing.T) { assert.NoError(t, err) assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, apnsID, res.ApnsID) + assert.Equal(t, apnsUniqueID, res.ApnsUniqueID) assert.Equal(t, true, res.Sent()) } diff --git a/notification.go b/notification.go index 69bf312d..54c8cafd 100644 --- a/notification.go +++ b/notification.go @@ -63,6 +63,20 @@ 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 is used for Live Activities that display various + // real-time information. If you set this push type, the topic field must + // use your app’s bundle ID with `push-type.liveactivity` appended to the end. + // The live activity push supports only token-based authentication. This + // push type is recommended for iOS. It is not available on macOS, tvOS, + // watchOS and iPadOS. + PushTypeLiveActivity EPushType = "liveactivity" + + // PushTypePushToTalk is used for notifications that provide information about the + // push to talk. If you set this push type, the apns-topic header field + // must use your app’s bundle ID with.voip-ptt appended to the end. + // The pushtotalk push type isn’t available on watchOS, macOS, and tvOS. It’s recommended on iOS and iPadOS. + PushTypePushToTalk EPushType = "pushtotalk" ) const ( diff --git a/payload/builder.go b/payload/builder.go index a2ff30da..dbda145c 100644 --- a/payload/builder.go +++ b/payload/builder.go @@ -23,6 +23,17 @@ const ( InterruptionLevelCritical EInterruptionLevel = "critical" ) +// LiveActivityEvent defines the value for the payload aps event +type ELiveActivityEvent string + +const ( + // LiveActivityEventUpdate is used to update an live activity. + LiveActivityEventUpdate ELiveActivityEvent = "update" + + // LiveActivityEventEnd is used to end an live activity. + LiveActivityEventEnd ELiveActivityEvent = "end" +) + // Payload represents a notification which holds the content that will be // marshalled as JSON. type Payload struct { @@ -30,16 +41,23 @@ type Payload struct { } 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"` + ContentState map[string]interface{} `json:"content-state,omitempty"` + DismissalDate int64 `json:"dismissal-date,omitempty"` + StaleDate int64 `json:"stale-date,omitempty"` + Event ELiveActivityEvent `json:"event,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + AttributesType string `json:"attributes-type,omitempty"` + Attributes map[string]interface{} `json:"attributes,omitempty"` } type alert struct { @@ -53,6 +71,8 @@ type alert struct { Subtitle string `json:"subtitle,omitempty"` TitleLocArgs []string `json:"title-loc-args,omitempty"` TitleLocKey string `json:"title-loc-key,omitempty"` + SubtitleLocArgs []string `json:"subtitle-loc-args,omitempty"` + SubtitleLocKey string `json:"subtitle-loc-key,omitempty"` SummaryArg string `json:"summary-arg,omitempty"` SummaryArgCount int `json:"summary-arg-count,omitempty"` } @@ -81,6 +101,69 @@ func (p *Payload) Alert(alert interface{}) *Payload { return p } +// SetContentState sets the aps content-state on the payload. +// This will update content-state of live activity widget. +// +// {"aps":{"content-state": {} }}` +func (p *Payload) SetContentState(contentState map[string]interface{}) *Payload { + p.aps().ContentState = contentState + return p +} + +// SetDismissalDate sets the aps dismissal-date on the payload. +// This will remove the live activity from the user's UI at the given timestamp. +// +// {"aps":{"dismissal-date": DismissalDate }}` +func (p *Payload) SetDismissalDate(dismissalDate int64) *Payload { + p.aps().DismissalDate = dismissalDate + return p +} + +// SetStaleDate sets the aps stale-date on the payload. +// This will mark this live activity update as outdated at the given timestamp. +// +// {"aps":{"stale-date": StaleDate }}` +func (p *Payload) SetStaleDate(staleDate int64) *Payload { + p.aps().StaleDate = staleDate + return p +} + +// SetEvent sets the aps event type on the payload. +// This can either be `LiveActivityEventUpdate` or `LiveActivityEventEnd` +// +// {"aps":{"event": Event }}` +func (p *Payload) SetEvent(event ELiveActivityEvent) *Payload { + p.aps().Event = event + return p +} + +// SetTimestamp sets the aps timestamp on the payload. +// This will let live activity know when to update the stuff. +// +// {"aps":{"timestamp": Timestamp }}` +func (p *Payload) SetTimestamp(timestamp int64) *Payload { + p.aps().Timestamp = timestamp + return p +} + +// SetAttributesType sets the aps attributes-type field on the payload. +// This is used for push-to-start live activities +// +// {"aps":{"attributes-type": attributesType }}` +func (p *Payload) SetAttributesType(attributesType string) *Payload { + p.aps().AttributesType = attributesType + return p +} + +// SetAttributes sets the aps attributes field on the payload. +// This is used for push-to-start live activities +// +// {"aps":{"attributes": attributes }}` +func (p *Payload) SetAttributes(attributes map[string]interface{}) *Payload { + p.aps().Attributes = attributes + return p +} + // Badge sets the aps badge on the payload. // This will display a numeric badge on the app icon. // @@ -193,6 +276,28 @@ func (p *Payload) AlertSubtitle(subtitle string) *Payload { return p } +// AlertSubtitleLocKey sets the aps alert subtitle localization key on the payload. +// This is the key to a subtitle string in the Localizable.strings file for the +// current localization. See Localized Formatted Strings in Apple documentation +// for more information. +// +// {"aps":{"alert":{"subtitle-loc-key":key}}} +func (p *Payload) AlertSubtitleLocKey(key string) *Payload { + p.aps().alert().SubtitleLocKey = key + return p +} + +// AlertSubtitleLocArgs sets the aps alert subtitle localization args on the payload. +// These are the variable string values to appear in place of the format +// specifiers in subtitle-loc-key. See Localized Formatted Strings in Apple +// documentation for more information. +// +// {"aps":{"alert":{"title-loc-args":args}}} +func (p *Payload) AlertSubtitleLocArgs(args []string) *Payload { + p.aps().alert().SubtitleLocArgs = args + return p +} + // AlertBody sets the aps alert body on the payload. // This is the text of the alert message. // @@ -218,7 +323,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 diff --git a/payload/builder_test.go b/payload/builder_test.go index a9650206..cfde3817 100644 --- a/payload/builder_test.go +++ b/payload/builder_test.go @@ -3,6 +3,7 @@ package payload_test import ( "encoding/json" "testing" + "time" . "github.com/sideshow/apns2/payload" "github.com/stretchr/testify/assert" @@ -110,6 +111,18 @@ func TestAlertSubtitle(t *testing.T) { assert.Equal(t, `{"aps":{"alert":{"subtitle":"hello"}}}`, string(b)) } +func TestAlertSubtitleLocKey(t *testing.T) { + payload := NewPayload().AlertSubtitleLocKey("Notification.Key.TestSubtitle") + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"alert":{"subtitle-loc-key":"Notification.Key.TestSubtitle"}}}`, string(b)) +} + +func TestAlertSubtitleLocArgs(t *testing.T) { + payload := NewPayload().AlertSubtitleLocArgs([]string{"one", "two"}) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"alert":{"subtitle-loc-args":["one","two"]}}}`, string(b)) +} + func TestAlertBody(t *testing.T) { payload := NewPayload().AlertBody("body") b, _ := json.Marshal(payload) @@ -146,6 +159,66 @@ func TestCategory(t *testing.T) { assert.Equal(t, `{"aps":{"category":"NEW_MESSAGE_CATEGORY"}}`, string(b)) } +func TestContentState(t *testing.T) { + payload := NewPayload().SetContentState(map[string]interface{}{"my_int": 13, "my_string": "foo"}) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"content-state":{"my_int":13,"my_string":"foo"}}}`, string(b)) +} + +func TestDismissalDate(t *testing.T) { + timestamp := time.Date(2023, 1, 27, 12, 14, 00, 00, time.UTC).Unix() + payload := NewPayload().SetDismissalDate(timestamp) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"dismissal-date":1674821640}}`, string(b)) +} + +func TestStaleDate(t *testing.T) { + timestamp := time.Date(2023, 1, 27, 12, 14, 00, 00, time.UTC).Unix() + payload := NewPayload().SetStaleDate(timestamp) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"stale-date":1674821640}}`, string(b)) +} + +func TestEventEnd(t *testing.T) { + payload := NewPayload().SetEvent(LiveActivityEventEnd) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"event":"end"}}`, string(b)) +} + +func TestEventUpdate(t *testing.T) { + payload := NewPayload().SetEvent(LiveActivityEventUpdate) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"event":"update"}}`, string(b)) +} + +func TestTimestamp(t *testing.T) { + timestamp := time.Date(2023, 1, 27, 12, 14, 00, 00, time.UTC).Unix() + payload := NewPayload().SetTimestamp(timestamp) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"timestamp":1674821640}}`, string(b)) +} + +func TestAttributesType(t *testing.T) { + attributesType := "AdventureAttributes" + payload := NewPayload().SetAttributesType(attributesType) + b, _ := json.Marshal(payload) + assert.Equal(t, `{"aps":{"attributes-type":"AdventureAttributes"}}`, string(b)) +} + +func TestAttributes(t *testing.T) { + attributes := map[string]interface{}{ + "currentHealthLevel": 100, + "eventDescription": "Adventure has begun!", + } + payload := NewPayload().SetAttributes(attributes) + b, _ := json.Marshal(payload) + assert.Equal( + t, + `{"aps":{"attributes":{"currentHealthLevel":100,"eventDescription":"Adventure has begun!"}}}`, + string(b), + ) +} + func TestMdm(t *testing.T) { payload := NewPayload().Mdm("996ac527-9993-4a0a-8528-60b2b3c2f52b") b, _ := json.Marshal(payload) diff --git a/response.go b/response.go index 99d63456..55609d39 100644 --- a/response.go +++ b/response.go @@ -84,6 +84,9 @@ const ( // 405 The specified :method was not POST. ReasonMethodNotAllowed = "MethodNotAllowed" + // 410 The device token has expired. + ReasonExpiredToken = "ExpiredToken" + // 410 The device token is inactive for the specified topic. ReasonUnregistered = "Unregistered" @@ -132,6 +135,11 @@ type Response struct { // If the value of StatusCode is 410, this is the last time at which APNs // confirmed that the device token was no longer valid for the topic. Timestamp Time + + // An identifier that is only available in the Developement enviroment. Use + // this to query Delivery Log information for the corresponding notification + // in Push Notifications Console. + ApnsUniqueID string } // Sent returns whether or not the notification was successfully sent. diff --git a/token/token.go b/token/token.go index 26fec563..bf26e1a9 100644 --- a/token/token.go +++ b/token/token.go @@ -5,7 +5,7 @@ import ( "crypto/x509" "encoding/pem" "errors" - "io/ioutil" + "os" "sync" "time" @@ -40,7 +40,7 @@ type Token struct { // AuthKeyFromFile loads a .p8 certificate from a local file and returns a // *ecdsa.PrivateKey. func AuthKeyFromFile(filename string) (*ecdsa.PrivateKey, error) { - bytes, err := ioutil.ReadFile(filename) + bytes, err := os.ReadFile(filename) if err != nil { return nil, err } diff --git a/token/token_test.go b/token/token_test.go index ef3c6fd1..87a45aab 100644 --- a/token/token_test.go +++ b/token/token_test.go @@ -5,7 +5,7 @@ import ( "crypto/elliptic" "crypto/rand" "errors" - "io/ioutil" + "os" "testing" "time" @@ -21,7 +21,7 @@ func TestValidTokenFromP8File(t *testing.T) { } func TestValidTokenFromP8Bytes(t *testing.T) { - bytes, _ := ioutil.ReadFile("_fixtures/authkey-valid.p8") + bytes, _ := os.ReadFile("_fixtures/authkey-valid.p8") _, err := token.AuthKeyFromBytes(bytes) assert.NoError(t, err) }