Skip to content
2 changes: 1 addition & 1 deletion config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ gcm:
concurrentWorkers: 10
queue:
topics:
- "^push-[^-_]+_(apns|gcm)[_-](single|massive)"
- "^push-[^-_]+_(apns|gcm|ios)[_-](single|massive)"
brokers: "localhost:9941"
group: testGroup
sessionTimeout: 6000
Expand Down
2 changes: 1 addition & 1 deletion config/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ gcm:
concurrentWorkers: 10
queue:
topics:
- "^push-[^-_]+_(apns|gcm)[_-](single|massive)"
- "^push-[^-_]+_(apns|gcm|ios)[_-](single|massive)"
brokers: "localhost:9941"
group: testGroup
sessionTimeout: 6000
Expand Down
2 changes: 1 addition & 1 deletion extensions/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
"github.com/topfreegames/pusher/interfaces"
)

var topicRegex = regexp.MustCompile("^push-([\\w]+(?:[_-][\\w]+)*)[-_](gcm|apns)")
var topicRegex = regexp.MustCompile("^push-([\\w]+(?:[_-][\\w]+)*)[-_](gcm|apns|ios)")

// ParsedTopic contains game and platform extracted from topic name
type ParsedTopic struct {
Expand Down
44 changes: 44 additions & 0 deletions extensions/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,49 @@ var _ = Describe("Common", func() {
Expect(err.Error()).To(Equal("json: unsupported type: chan int"))
})
})

Describe("GetGameAndPlatformFromTopic", func() {
It("should parse gcm single topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_gcm-single")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("gcm"))
})

It("should parse gcm massive topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_gcm-massive")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("gcm"))
})

It("should parse apns single topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_apns-single")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("apns"))
})

It("should parse apns massive topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_apns-massive")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("apns"))
})

It("should parse ios single topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_ios-single")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("ios"))
})

It("should parse ios massive topic", func() {
parsed := GetGameAndPlatformFromTopic("push-mygame_ios-massive")
Expect(parsed.Game).To(Equal("mygame"))
Expect(parsed.Platform).To(Equal("ios"))
})

It("should parse ios topic with compound game name", func() {
parsed := GetGameAndPlatformFromTopic("push-com_my_game_ios-single")
Expect(parsed.Game).To(Equal("com_my_game"))
Expect(parsed.Platform).To(Equal("ios"))
})
})
})
})
70 changes: 70 additions & 0 deletions extensions/firebase/client/firebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"time"

firebase "firebase.google.com/go/v4"
Expand Down Expand Up @@ -94,6 +95,13 @@ func getProjectIDFromJson(jsonStr string) (string, error) {
}

func toFirebaseMessage(message interfaces.Message) messaging.Message {
if message.Platform == "ios" {
return buildIOSMessage(message)
}
return buildAndroidMessage(message)
}

func buildAndroidMessage(message interfaces.Message) messaging.Message {
firebaseMessage := messaging.Message{
Token: message.To,
}
Expand Down Expand Up @@ -141,6 +149,68 @@ func toFirebaseMessage(message interfaces.Message) messaging.Message {
return firebaseMessage
}

func buildIOSMessage(message interfaces.Message) messaging.Message {
Comment thread
code-lucasgabriel marked this conversation as resolved.
firebaseMessage := messaging.Message{
Token: message.To,
}

if message.Data != nil {
firebaseMessage.Data = toMapString(message.Data)
}

apns := &messaging.APNSConfig{
Headers: map[string]string{},
}
if message.CollapseKey != "" {
apns.Headers["apns-collapse-id"] = message.CollapseKey
}
if message.Priority != "" {
apns.Headers["apns-priority"] = message.Priority
}
if message.TimeToLive != nil {
expiration := time.Now().Add(time.Duration(*message.TimeToLive) * time.Second).Unix()
apns.Headers["apns-expiration"] = strconv.FormatInt(expiration, 10)
}

aps := &messaging.Aps{
ContentAvailable: message.ContentAvailable,
}

if message.Notification != nil {
firebaseMessage.Notification = &messaging.Notification{
Title: message.Notification.Title,
Body: message.Notification.Body,
ImageURL: message.Notification.ImageUrl,
}

alert := &messaging.ApsAlert{
Title: message.Notification.Title,
Body: message.Notification.Body,
LocKey: message.Notification.BodyLocKey,
TitleLocKey: message.Notification.TitleLocKey,
}
if message.Notification.BodyLocArgs != "" {
alert.LocArgs = []string{message.Notification.BodyLocArgs}
}
if message.Notification.TitleLocArgs != "" {
alert.TitleLocArgs = []string{message.Notification.TitleLocArgs}
}
aps.Alert = alert
aps.Sound = message.Notification.Sound

if message.Notification.Badge != "" {
if badge, err := strconv.Atoi(message.Notification.Badge); err == nil {
aps.Badge = &badge
}
}
}

apns.Payload = &messaging.APNSPayload{Aps: aps}
firebaseMessage.APNS = apns

return firebaseMessage
}

func toMapString(data interfaces.Data) map[string]string {
result := make(map[string]string)
for k, v := range data {
Expand Down
210 changes: 210 additions & 0 deletions extensions/firebase/client/firebase_apns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package client

import (
"strconv"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/topfreegames/pusher/interfaces"
)

func TestToFirebaseMessage_IOSBuildsAPNSOnly(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
Title: "hello",
Body: "world",
Sound: "default",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.APNS)
require.NotNil(t, out.APNS.Payload)
require.NotNil(t, out.APNS.Payload.Aps)
require.NotNil(t, out.APNS.Payload.Aps.Alert)
assert.Equal(t, "hello", out.APNS.Payload.Aps.Alert.Title)
assert.Equal(t, "world", out.APNS.Payload.Aps.Alert.Body)
assert.Equal(t, "default", out.APNS.Payload.Aps.Sound)
assert.Nil(t, out.Android, "iOS message must not populate Android config")
}

func TestToFirebaseMessage_GCMBuildsAndroidOnly(t *testing.T) {
msg := interfaces.Message{
To: "android-token",
Platform: "gcm",
Notification: &interfaces.Notification{
Title: "hello",
Body: "world",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.Android)
require.NotNil(t, out.Android.Notification)
assert.Equal(t, "hello", out.Android.Notification.Title)
assert.Nil(t, out.APNS, "Android message must not populate APNS config")
}

func TestToFirebaseMessage_EmptyPlatformDefaultsToAndroid(t *testing.T) {
msg := interfaces.Message{
To: "android-token",
Platform: "",
Notification: &interfaces.Notification{
Title: "hello",
Body: "world",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.Android)
assert.Nil(t, out.APNS)
}

func TestBuildIOSMessage_BadgeStringToInt(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
Title: "t",
Body: "b",
Badge: "42",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.APNS.Payload.Aps.Badge)
assert.Equal(t, 42, *out.APNS.Payload.Aps.Badge)
}

func TestBuildIOSMessage_BadgeInvalidStringIsOmitted(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
Title: "t",
Body: "b",
Badge: "not-a-number",
},
}

out := toFirebaseMessage(msg)

assert.Nil(t, out.APNS.Payload.Aps.Badge)
}

func TestBuildIOSMessage_BadgeEmptyIsOmitted(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
Title: "t",
Body: "b",
},
}

out := toFirebaseMessage(msg)

assert.Nil(t, out.APNS.Payload.Aps.Badge)
}

func TestBuildIOSMessage_SilentPush(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
ContentAvailable: true,
Data: interfaces.Data{"k": "v"},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.APNS)
require.NotNil(t, out.APNS.Payload)
require.NotNil(t, out.APNS.Payload.Aps)
assert.True(t, out.APNS.Payload.Aps.ContentAvailable)
assert.Nil(t, out.APNS.Payload.Aps.Alert, "silent push must not include an alert")
assert.Nil(t, out.Notification, "silent push must not include a top-level Notification")
assert.Equal(t, map[string]string{"k": "v"}, out.Data)
}

func TestBuildIOSMessage_CollapseKeyAndTTLProduceHeaders(t *testing.T) {
ttl := uint(60)
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
CollapseKey: "collapse-1",
Priority: "10",
TimeToLive: &ttl,
Notification: &interfaces.Notification{
Title: "t",
Body: "b",
},
}

before := time.Now().Unix()
out := toFirebaseMessage(msg)
after := time.Now().Unix()

require.NotNil(t, out.APNS)
headers := out.APNS.Headers
assert.Equal(t, "collapse-1", headers["apns-collapse-id"])
assert.Equal(t, "10", headers["apns-priority"])

expStr, ok := headers["apns-expiration"]
require.True(t, ok, "apns-expiration header must be set when TTL provided")
exp, err := strconv.ParseInt(expStr, 10, 64)
require.NoError(t, err)
assert.GreaterOrEqual(t, exp, before+int64(ttl))
assert.LessOrEqual(t, exp, after+int64(ttl))
}

func TestBuildIOSMessage_OmitsHeadersWhenNotProvided(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
Title: "t",
Body: "b",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.APNS)
_, hasCollapse := out.APNS.Headers["apns-collapse-id"]
_, hasPriority := out.APNS.Headers["apns-priority"]
_, hasExpiration := out.APNS.Headers["apns-expiration"]
assert.False(t, hasCollapse)
assert.False(t, hasPriority)
assert.False(t, hasExpiration)
}

func TestBuildIOSMessage_LocKeysAndArgs(t *testing.T) {
msg := interfaces.Message{
To: "ios-token",
Platform: "ios",
Notification: &interfaces.Notification{
BodyLocKey: "body.key",
BodyLocArgs: "arg1",
TitleLocKey: "title.key",
TitleLocArgs: "title-arg",
},
}

out := toFirebaseMessage(msg)

require.NotNil(t, out.APNS.Payload.Aps.Alert)
alert := out.APNS.Payload.Aps.Alert
assert.Equal(t, "body.key", alert.LocKey)
assert.Equal(t, []string{"arg1"}, alert.LocArgs)
assert.Equal(t, "title.key", alert.TitleLocKey)
assert.Equal(t, []string{"title-arg"}, alert.TitleLocArgs)
}

Loading
Loading