From 8ffab049953d765c376225968e09cc7e9addac62 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 9 Jul 2026 14:55:23 -0400 Subject: [PATCH 1/2] feat(fcm): Add Support for AndroidConfigV2 --- messaging/messaging.go | 226 ++++++++++++++++++++++++++++++++++- messaging/messaging_batch.go | 14 ++- messaging/messaging_test.go | 149 ++++++++++++++++++++++- messaging/messaging_utils.go | 66 ++++++++++ snippets/messaging.go | 72 +++++++++++ 5 files changed, 514 insertions(+), 13 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 6c48477e..4b52d509 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -65,10 +65,12 @@ var ( type Message struct { Data map[string]string `json:"data,omitempty"` Notification *Notification `json:"notification,omitempty"` - Android *AndroidConfig `json:"android,omitempty"` - Webpush *WebpushConfig `json:"webpush,omitempty"` - APNS *APNSConfig `json:"apns,omitempty"` - FCMOptions *FCMOptions `json:"fcm_options,omitempty"` + // Deprecated: Use AndroidV2 instead. + Android *AndroidConfig `json:"android,omitempty"` + AndroidV2 *AndroidConfigV2 `json:"androidV2,omitempty"` + Webpush *WebpushConfig `json:"webpush,omitempty"` + APNS *APNSConfig `json:"apns,omitempty"` + FCMOptions *FCMOptions `json:"fcm_options,omitempty"` // Deprecated: Use Fid instead. Token string `json:"token,omitempty"` Topic string `json:"-"` @@ -115,6 +117,8 @@ type Notification struct { } // AndroidConfig contains messaging options specific to the Android platform. +// +// Deprecated: Use AndroidConfigV2 instead. type AndroidConfig struct { CollapseKey string `json:"collapse_key,omitempty"` Priority string `json:"priority,omitempty"` // one of "normal" or "high" @@ -168,7 +172,74 @@ func (a *AndroidConfig) UnmarshalJSON(b []byte) error { return nil } +// AndroidConfigV2 contains messaging options specific to the Android platform in the V2 format. +type AndroidConfigV2 struct { + CollapseKey string `json:"collapse_key,omitempty"` + TTL *time.Duration `json:"-"` + RestrictedPackageName string `json:"restricted_package_name,omitempty"` + Data map[string]string `json:"data,omitempty"` + FCMOptions *AndroidFCMOptions `json:"fcm_options,omitempty"` + DirectBootOK bool `json:"direct_boot_ok,omitempty"` + BandwidthConstrainedOK bool `json:"bandwidth_constrained_ok,omitempty"` + RestrictedSatelliteOK bool `json:"restricted_satellite_ok,omitempty"` + RemoteNotification *AndroidRemoteNotification `json:"remote_notification,omitempty"` + BackgroundSync *AndroidBackgroundSyncMessage `json:"background_sync,omitempty"` +} + +// MarshalJSON marshals an AndroidConfigV2 into JSON (for internal use only). +func (a *AndroidConfigV2) MarshalJSON() ([]byte, error) { + var ttl string + if a.TTL != nil { + ttl = durationToString(*a.TTL) + } + + type androidInternal AndroidConfigV2 + temp := &struct { + TTL string `json:"ttl,omitempty"` + *androidInternal + }{ + TTL: ttl, + androidInternal: (*androidInternal)(a), + } + return json.Marshal(temp) +} + +// UnmarshalJSON unmarshals a JSON string into an AndroidConfigV2 (for internal use only). +func (a *AndroidConfigV2) UnmarshalJSON(b []byte) error { + type androidInternal AndroidConfigV2 + temp := struct { + TTL string `json:"ttl,omitempty"` + *androidInternal + }{ + androidInternal: (*androidInternal)(a), + } + if err := json.Unmarshal(b, &temp); err != nil { + return err + } + if temp.TTL != "" { + ttl, err := stringToDuration(temp.TTL) + if err != nil { + return err + } + a.TTL = &ttl + } + return nil +} + +// AndroidRemoteNotification is a remote notification configuration. +type AndroidRemoteNotification struct { + MutableContent bool `json:"mutable_content,omitempty"` + Notification *AndroidNotificationV2 `json:"notification"` + UseAsV1DataMessage bool `json:"use_as_v1_data_message,omitempty"` +} + +// AndroidBackgroundSyncMessage is a background sync message configuration. +type AndroidBackgroundSyncMessage struct { +} + // AndroidNotification is a notification to send to Android devices. +// +// Deprecated: Use AndroidNotificationV2 instead. type AndroidNotification struct { Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type @@ -342,6 +413,150 @@ func (a *AndroidNotification) UnmarshalJSON(b []byte) error { return nil } +// AndroidNotificationV2 is a notification to send to Android devices in the V2 format. +type AndroidNotificationV2 struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + Sound string `json:"sound,omitempty"` + Tag string `json:"tag,omitempty"` + ClickAction string `json:"click_action,omitempty"` + BodyLocKey string `json:"body_loc_key,omitempty"` + BodyLocArgs []string `json:"body_loc_args,omitempty"` + TitleLocKey string `json:"title_loc_key,omitempty"` + TitleLocArgs []string `json:"title_loc_args,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + ImageURL string `json:"image,omitempty"` + Ticker string `json:"ticker,omitempty"` + Sticky bool `json:"sticky,omitempty"` + EventTimestamp *time.Time `json:"-"` + LocalOnly bool `json:"local_only,omitempty"` + Priority AndroidNotificationPriority `json:"-"` + VibrateTimingMillis []int64 `json:"-"` + DefaultVibrateTimings bool `json:"default_vibrate_timings,omitempty"` + DefaultSound bool `json:"default_sound,omitempty"` + LightSettings *LightSettings `json:"light_settings,omitempty"` + DefaultLightSettings bool `json:"default_light_settings,omitempty"` + Visibility AndroidNotificationVisibility `json:"-"` + NotificationCount *int `json:"notification_count,omitempty"` + ID *int `json:"id,omitempty"` +} + +// MarshalJSON marshals an AndroidNotificationV2 into JSON (for internal use only). +func (a *AndroidNotificationV2) MarshalJSON() ([]byte, error) { + var priority string + if a.Priority != priorityUnspecified { + priorities := map[AndroidNotificationPriority]string{ + PriorityMin: "PRIORITY_MIN", + PriorityLow: "PRIORITY_LOW", + PriorityDefault: "PRIORITY_DEFAULT", + PriorityHigh: "PRIORITY_HIGH", + PriorityMax: "PRIORITY_MAX", + } + priority, _ = priorities[a.Priority] + } + + var visibility string + if a.Visibility != visibilityUnspecified { + visibilities := map[AndroidNotificationVisibility]string{ + VisibilityPrivate: "PRIVATE", + VisibilityPublic: "PUBLIC", + VisibilitySecret: "SECRET", + } + visibility, _ = visibilities[a.Visibility] + } + + var timestamp string + if a.EventTimestamp != nil { + timestamp = a.EventTimestamp.UTC().Format(rfc3339Zulu) + } + + var vibTimings []string + for _, t := range a.VibrateTimingMillis { + vibTimings = append(vibTimings, durationToString(time.Duration(t)*time.Millisecond)) + } + + type androidInternal AndroidNotificationV2 + temp := &struct { + EventTimestamp string `json:"event_time,omitempty"` + Priority string `json:"notification_priority,omitempty"` + Visibility string `json:"visibility,omitempty"` + VibrateTimings []string `json:"vibrate_timings,omitempty"` + *androidInternal + }{ + EventTimestamp: timestamp, + Priority: priority, + Visibility: visibility, + VibrateTimings: vibTimings, + androidInternal: (*androidInternal)(a), + } + return json.Marshal(temp) +} + +// UnmarshalJSON unmarshals a JSON string into an AndroidNotificationV2 (for internal use only). +func (a *AndroidNotificationV2) UnmarshalJSON(b []byte) error { + type androidInternal AndroidNotificationV2 + temp := struct { + EventTimestamp string `json:"event_time,omitempty"` + Priority string `json:"notification_priority,omitempty"` + Visibility string `json:"visibility,omitempty"` + VibrateTimings []string `json:"vibrate_timings,omitempty"` + *androidInternal + }{ + androidInternal: (*androidInternal)(a), + } + if err := json.Unmarshal(b, &temp); err != nil { + return err + } + + if temp.EventTimestamp != "" { + parsedTime, err := time.Parse(rfc3339Zulu, temp.EventTimestamp) + if err != nil { + return err + } + a.EventTimestamp = &parsedTime + } + + if temp.Priority != "" { + priorities := map[string]AndroidNotificationPriority{ + "PRIORITY_MIN": PriorityMin, + "PRIORITY_LOW": PriorityLow, + "PRIORITY_DEFAULT": PriorityDefault, + "PRIORITY_HIGH": PriorityHigh, + "PRIORITY_MAX": PriorityMax, + } + if prio, ok := priorities[temp.Priority]; ok { + a.Priority = prio + } else { + return fmt.Errorf("unknown priority value: %q", temp.Priority) + } + } + + if temp.Visibility != "" { + visibilities := map[string]AndroidNotificationVisibility{ + "PRIVATE": VisibilityPrivate, + "PUBLIC": VisibilityPublic, + "SECRET": VisibilitySecret, + } + if vis, ok := visibilities[temp.Visibility]; ok { + a.Visibility = vis + } else { + return fmt.Errorf("unknown visibility value: %q", temp.Visibility) + } + } + + for _, s := range temp.VibrateTimings { + d, err := stringToDuration(s) + if err != nil { + return err + } + a.VibrateTimingMillis = append(a.VibrateTimingMillis, int64(d/time.Millisecond)) + } + + return nil +} + // AndroidNotificationPriority represents the priority levels of a notification. type AndroidNotificationPriority int @@ -388,8 +603,11 @@ const ( ) // AndroidNotificationProxy to control when a notification may be proxied. +// +// Deprecated: AndroidNotificationProxy is not supported in the V2 API. Use AndroidConfigV2 instead. type AndroidNotificationProxy int +// Deprecated: AndroidNotificationProxy constants are not supported in the V2 API. Use AndroidConfigV2 instead. const ( proxyUnspecified AndroidNotificationProxy = iota diff --git a/messaging/messaging_batch.go b/messaging/messaging_batch.go index 7dc6c110..1db180b6 100644 --- a/messaging/messaging_batch.go +++ b/messaging/messaging_batch.go @@ -45,11 +45,13 @@ type MulticastMessage struct { Tokens []string Data map[string]string Notification *Notification - Android *AndroidConfig - Webpush *WebpushConfig - APNS *APNSConfig - FCMOptions *FCMOptions - Fids []string + // Deprecated: Use AndroidV2 instead. + Android *AndroidConfig + AndroidV2 *AndroidConfigV2 + Webpush *WebpushConfig + APNS *APNSConfig + FCMOptions *FCMOptions + Fids []string } func (mm *MulticastMessage) toMessages() ([]*Message, error) { @@ -68,6 +70,7 @@ func (mm *MulticastMessage) toMessages() ([]*Message, error) { Data: mm.Data, Notification: mm.Notification, Android: mm.Android, + AndroidV2: mm.AndroidV2, Webpush: mm.Webpush, APNS: mm.APNS, FCMOptions: mm.FCMOptions, @@ -80,6 +83,7 @@ func (mm *MulticastMessage) toMessages() ([]*Message, error) { Data: mm.Data, Notification: mm.Notification, Android: mm.Android, + AndroidV2: mm.AndroidV2, Webpush: mm.Webpush, APNS: mm.APNS, FCMOptions: mm.FCMOptions, diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 00d4fee9..85d1b425 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -44,10 +44,12 @@ var ( ttl = time.Duration(10) * time.Second invalidTTL = time.Duration(-10) * time.Second - badge = 42 - badgeZero = 0 - timestampMillis = int64(12345) - timestamp = time.Unix(0, 1546304523123*1000000).UTC() + badge = 42 + badgeZero = 0 + timestampMillis = int64(12345) + timestamp = time.Unix(0, 1546304523123*1000000).UTC() + notificationCount = 67 + notificationID = 100 ) var validMessages = []struct { @@ -764,6 +766,115 @@ var validMessages = []struct { "topic": "test-topic", }, }, + { + name: "AndroidV2RemoteNotification", + req: &Message{ + AndroidV2: &AndroidConfigV2{ + RestrictedPackageName: "rpn", + RemoteNotification: &AndroidRemoteNotification{ + MutableContent: true, + UseAsV1DataMessage: true, + Notification: &AndroidNotificationV2{ + Title: "t", + Body: "b", + Color: "#112233", + Sound: "s", + TitleLocKey: "tlk", + TitleLocArgs: []string{"t1", "t2"}, + BodyLocKey: "blk", + BodyLocArgs: []string{"b1", "b2"}, + ChannelID: "channel", + ImageURL: "http://image.jpg", + Ticker: "tkr", + Sticky: true, + EventTimestamp: ×tamp, + LocalOnly: true, + Priority: PriorityMax, + VibrateTimingMillis: []int64{100, 50, 100}, + DefaultVibrateTimings: true, + DefaultSound: true, + LightSettings: &LightSettings{ + Color: "#33669966", + LightOnDurationMillis: 100, + LightOffDurationMillis: 50, + }, + Visibility: VisibilityPrivate, + DefaultLightSettings: true, + NotificationCount: ¬ificationCount, + ID: ¬ificationID, + }, + }, + TTL: &ttlWithNanos, + FCMOptions: &AndroidFCMOptions{ + AnalyticsLabel: "Analytics", + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "androidV2": map[string]interface{}{ + "restricted_package_name": "rpn", + "remote_notification": map[string]interface{}{ + "mutable_content": true, + "use_as_v1_data_message": true, + "notification": map[string]interface{}{ + "title": "t", + "body": "b", + "color": "#112233", + "sound": "s", + "title_loc_key": "tlk", + "title_loc_args": []interface{}{"t1", "t2"}, + "body_loc_key": "blk", + "body_loc_args": []interface{}{"b1", "b2"}, + "channel_id": "channel", + "image": "http://image.jpg", + "ticker": "tkr", + "sticky": true, + "event_time": "2019-01-01T01:02:03.123000000Z", + "local_only": true, + "notification_priority": "PRIORITY_MAX", + "vibrate_timings": []interface{}{"0.100000000s", "0.050000000s", "0.100000000s"}, + "default_vibrate_timings": true, + "default_sound": true, + "light_settings": map[string]interface{}{ + "color": map[string]interface{}{ + "red": float64(0.2), + "green": float64(0.4), + "blue": float64(0.6), + "alpha": float64(0.4), + }, + "light_on_duration": "0.100000000s", + "light_off_duration": "0.050000000s", + }, + "visibility": "PRIVATE", + "default_light_settings": true, + "notification_count": float64(67), + "id": float64(100), + }, + }, + "ttl": "1.500000000s", + "fcm_options": map[string]interface{}{ + "analytics_label": "Analytics", + }, + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidV2BackgroundSync", + req: &Message{ + AndroidV2: &AndroidConfigV2{ + BackgroundSync: &AndroidBackgroundSyncMessage{}, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "androidV2": map[string]interface{}{ + "background_sync": map[string]interface{}{}, + }, + "topic": "test-topic", + }, + }, } var invalidMessages = []struct { @@ -831,6 +942,36 @@ var invalidMessages = []struct { }, want: "ttl duration must not be negative", }, + { + name: "AndroidAndAndroidV2MutuallyExclusive", + req: &Message{ + Android: &AndroidConfig{}, + AndroidV2: &AndroidConfigV2{}, + Topic: "topic", + }, + want: "at most one of android or androidV2 can be specified; use AndroidConfigV2", + }, + { + name: "InvalidAndroidV2TTL", + req: &Message{ + AndroidV2: &AndroidConfigV2{ + TTL: &invalidTTL, + }, + Topic: "topic", + }, + want: "ttl duration must not be negative", + }, + { + name: "AndroidV2RemoteAndBackgroundMutuallyExclusive", + req: &Message{ + AndroidV2: &AndroidConfigV2{ + RemoteNotification: &AndroidRemoteNotification{}, + BackgroundSync: &AndroidBackgroundSyncMessage{}, + }, + Topic: "topic", + }, + want: "exactly one of remoteNotification or backgroundSync is required", + }, { name: "InvalidAndroidPriority", req: &Message{ diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go index 49fec654..32bbd678 100644 --- a/messaging/messaging_utils.go +++ b/messaging/messaging_utils.go @@ -52,10 +52,17 @@ func validateMessage(message *Message) error { } // validate AndroidConfig + if message.Android != nil && message.AndroidV2 != nil { + return fmt.Errorf("at most one of android or androidV2 can be specified; use AndroidConfigV2") + } if err := validateAndroidConfig(message.Android); err != nil { return err } + if err := validateAndroidConfigV2(message.AndroidV2); err != nil { + return err + } + // validate WebpushConfig if err := validateWebpushConfig(message.Webpush); err != nil { return err @@ -95,6 +102,26 @@ func validateAndroidConfig(config *AndroidConfig) error { return validateAndroidNotification(config.Notification) } +func validateAndroidConfigV2(config *AndroidConfigV2) error { + if config == nil { + return nil + } + + if config.TTL != nil && config.TTL.Seconds() < 0 { + return fmt.Errorf("ttl duration must not be negative") + } + + targets := countTrue(config.RemoteNotification != nil, config.BackgroundSync != nil) + if targets != 1 { + return fmt.Errorf("exactly one of remoteNotification or backgroundSync is required") + } + + if config.RemoteNotification == nil { + return nil + } + return validateAndroidNotificationV2(config.RemoteNotification.Notification) +} + func validateAndroidNotification(notification *AndroidNotification) error { if notification == nil { return nil @@ -123,6 +150,35 @@ func validateAndroidNotification(notification *AndroidNotification) error { return validateLightSettings(notification.LightSettings) } +func validateAndroidNotificationV2(notification *AndroidNotificationV2) error { + if notification == nil { + return nil + } + + if notification.Color != "" && !colorPattern.MatchString(notification.Color) { + return fmt.Errorf("color must be in the #RRGGBB form") + } + if len(notification.TitleLocArgs) > 0 && notification.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + if len(notification.BodyLocArgs) > 0 && notification.BodyLocKey == "" { + return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") + } + image := notification.ImageURL + if image != "" { + if _, err := url.ParseRequestURI(image); err != nil { + return fmt.Errorf("invalid image URL: %q", image) + } + } + for _, timing := range notification.VibrateTimingMillis { + if timing < 0 { + return fmt.Errorf("vibrateTimingMillis must not be negative") + } + } + + return validateLightSettings(notification.LightSettings) +} + func validateLightSettings(light *LightSettings) error { if light == nil { return nil @@ -243,3 +299,13 @@ func countNonEmpty(strings ...string) int { } return count } + +func countTrue(bools ...bool) int { + count := 0 + for _, b := range bools { + if b { + count++ + } + } + return count +} diff --git a/snippets/messaging.go b/snippets/messaging.go index 1b25cb1c..609be01a 100644 --- a/snippets/messaging.go +++ b/snippets/messaging.go @@ -312,6 +312,78 @@ func androidMessage() *messaging.Message { return message } +func androidV2RemoteNotificationMessage() *messaging.Message { + // [START android_v2_remote_notification_golang] + oneHour := time.Duration(1) * time.Hour + message := &messaging.Message{ + AndroidV2: &messaging.AndroidConfigV2{ + TTL: &oneHour, + RemoteNotification: &messaging.AndroidRemoteNotification{ + MutableContent: true, + UseAsV1DataMessage: true, + Notification: &messaging.AndroidNotificationV2{ + Title: "$GOOG up 1.43% on the day", + Body: "$GOOG gained 11.80 points to close at 835.67, up 1.43% on the day.", + Icon: "stock_ticker_update", + Color: "#f45342", + ImageURL: "https://my-server/image.png", + }, + }, + }, + Topic: "industry-tech", + } + // [END android_v2_remote_notification_golang] + return message +} + +func androidV2BackgroundSyncMessage() *messaging.Message { + // [START android_v2_background_sync_golang] + oneHour := time.Duration(1) * time.Hour + message := &messaging.Message{ + AndroidV2: &messaging.AndroidConfigV2{ + TTL: &oneHour, + BackgroundSync: &messaging.AndroidBackgroundSyncMessage{}, + }, + Data: map[string]string{ + "score": "341/5", + }, + Topic: "industry-tech", + } + // [END android_v2_background_sync_golang] + return message +} + +func allPlatformsV2Message() *messaging.Message { + // [START multi_platforms_v2_message_golang] + oneHour := time.Duration(1) * time.Hour + badge := 42 + message := &messaging.Message{ + Notification: &messaging.Notification{ + Title: "$GOOG up 1.43% on the day", + Body: "$GOOG gained 11.80 points to close at 835.67, up 1.43% on the day.", + }, + AndroidV2: &messaging.AndroidConfigV2{ + TTL: &oneHour, + RemoteNotification: &messaging.AndroidRemoteNotification{ + Notification: &messaging.AndroidNotificationV2{ + Icon: "stock_ticker_update", + Color: "#f45342", + }, + }, + }, + APNS: &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Badge: &badge, + }, + }, + }, + Topic: "industry-tech", + } + // [END multi_platforms_v2_message_golang] + return message +} + func apnsMessage() *messaging.Message { // [START apns_message_golang] badge := 42 From 47e47718fd9a02cc7a37bf3aa39596c7c748b0bd Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 9 Jul 2026 18:16:32 -0400 Subject: [PATCH 2/2] fix(fcm): Replace vibrate timings on struct reuse during unmarshal --- messaging/messaging.go | 13 ++++++++----- messaging/messaging_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 4b52d509..b438789d 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -546,12 +546,15 @@ func (a *AndroidNotificationV2) UnmarshalJSON(b []byte) error { } } - for _, s := range temp.VibrateTimings { - d, err := stringToDuration(s) - if err != nil { - return err + if temp.VibrateTimings != nil { + a.VibrateTimingMillis = make([]int64, 0, len(temp.VibrateTimings)) + for _, s := range temp.VibrateTimings { + d, err := stringToDuration(s) + if err != nil { + return err + } + a.VibrateTimingMillis = append(a.VibrateTimingMillis, int64(d/time.Millisecond)) } - a.VibrateTimingMillis = append(a.VibrateTimingMillis, int64(d/time.Millisecond)) } return nil diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 85d1b425..c2803094 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -1363,6 +1363,36 @@ func TestJSONUnmarshal(t *testing.T) { } } +func TestAndroidNotificationV2Unmarshal(t *testing.T) { + t.Run("ReusedStructReplacesTimings", func(t *testing.T) { + jsonStr := `{"vibrate_timings":["0.100000000s","0.200000000s"]}` + target := &AndroidNotificationV2{ + VibrateTimingMillis: []int64{500, 600, 700}, + } + if err := json.Unmarshal([]byte(jsonStr), target); err != nil { + t.Fatal(err) + } + want := []int64{100, 200} + if !reflect.DeepEqual(target.VibrateTimingMillis, want) { + t.Errorf("VibrateTimingMillis = %v; want = %v", target.VibrateTimingMillis, want) + } + }) + + t.Run("ReusedStructOmittedTimingsUnchanged", func(t *testing.T) { + jsonStr := `{"title":"test"}` + target := &AndroidNotificationV2{ + VibrateTimingMillis: []int64{500, 600, 700}, + } + if err := json.Unmarshal([]byte(jsonStr), target); err != nil { + t.Fatal(err) + } + want := []int64{500, 600, 700} + if !reflect.DeepEqual(target.VibrateTimingMillis, want) { + t.Errorf("VibrateTimingMillis = %v; want = %v", target.VibrateTimingMillis, want) + } + }) +} + func TestInvalidJSONUnmarshal(t *testing.T) { cases := []struct { name string