package fcm import ( "encoding/json" "errors" "strconv" "time" fcmv1 "google.golang.org/api/fcm/v1" "github.com/tinode/chat/server/drafty" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/push" "github.com/tinode/chat/server/push/common" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" "maps" ) const ( // TTL of a VOIP push notification in seconds. voipTimeToLive = 10 // TTL of a regular push notification in seconds. defaultTimeToLive = 3600 ) func payloadToData(pl *push.Payload) (map[string]string, error) { if pl == nil { return nil, errors.New("empty push payload") } data := make(map[string]string) var err error data["what"] = pl.What if pl.Silent { data["silent"] = "true" } data["topic"] = pl.Topic data["ts"] = pl.Timestamp.Format(time.RFC3339Nano) // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. data["xfrom"] = pl.From if pl.What == push.ActMsg { data["seq"] = strconv.FormatInt(pl.SeqId, 10) if pl.ContentType != "" { data["mime"] = pl.ContentType } // Convert Drafty content to plain text (clients 0.16 and below). data["content"], err = drafty.PlainText(pl.Content) if err != nil { return nil, err } // Trim long strings to 128 runes. // Check byte length first and don't waste time converting short strings. if len(data["content"]) > push.MaxPayloadLength { runes := []rune(data["content"]) if len(runes) > push.MaxPayloadLength { data["content"] = string(runes[:push.MaxPayloadLength]) + "…" } } // Rich content for clients version 0.17 and above. data["rc"], err = drafty.Preview(pl.Content, push.MaxPayloadLength) if pl.Webrtc != "" { data["webrtc"] = pl.Webrtc if pl.AudioOnly { data["aonly"] = "true" } // Video call push notifications are silent. data["silent"] = "true" } if pl.Replace != "" { // Notification of a message edit should be silent too. data["silent"] = "true" data["replace"] = pl.Replace } if err != nil { return nil, err } } else if pl.What == push.ActSub { data["modeWant"] = pl.ModeWant.String() data["modeGiven"] = pl.ModeGiven.String() } else if pl.What == push.ActRead { data["seq"] = strconv.FormatInt(pl.SeqId, 10) data["silent"] = "true" } else { return nil, errors.New("unknown push type") } return data, nil } func clonePayload(src map[string]string) map[string]string { dst := make(map[string]string, len(src)) maps.Copy(dst, src) return dst } // PrepareV1Notifications creates notification payloads ready to be posted // to push notification server for the provided receipt. func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Message, []t.Uid) { data, err := payloadToData(&rcpt.Payload) if err != nil { logs.Warn.Println("fcm push: could not parse payload:", err) return nil, nil } // Device IDs to send pushes to. var devices map[t.Uid][]t.DeviceDef // Count of device IDs to push to. var count int // Devices which were online in the topic when the message was sent. skipDevices := make(map[string]struct{}) if len(rcpt.To) > 0 { // List of UIDs for querying the database uids := make([]t.Uid, len(rcpt.To)) i := 0 for uid, to := range rcpt.To { uids[i] = uid i++ // Some devices were online and received the message. Skip them. for _, deviceID := range to.Devices { skipDevices[deviceID] = struct{}{} } } devices, count, err = pushDevicesForUsers(rcpt.TenantID, uids...) if err != nil { logs.Warn.Println("fcm push: db error", err) return nil, nil } } if count == 0 && rcpt.Channel == "" { return nil, nil } if config == nil { // config is nil when called from tnpg adapter; provide a blank one for simplicity. config = &configType{} } var messages []*fcmv1.Message var uids []t.Uid for uid, devList := range devices { topic := rcpt.Payload.Topic userData := data if rcpt.To[uid].Delivered > 0 { userData = clonePayload(data) // Silence the push for user who have received the data interactively. userData["silent"] = "true" } for i := range devList { d := &devList[i] if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" { msg := fcmv1.Message{ Token: d.DeviceId, Data: userData, } switch d.Platform { case "android": msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config) case "ios": msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, rcpt.To[uid].Unread, config) case "web": if config != nil && config.Webpush != nil && config.Webpush.Enabled { msg.Webpush = &fcmv1.WebpushConfig{} } case "": // ignore default: logs.Warn.Println("fcm: unknown device platform", d.Platform) } uids = append(uids, uid) messages = append(messages, &msg) } } } if rcpt.Channel != "" { topic := TenantChannelName(rcpt.TenantID, rcpt.Channel) userData := clonePayload(data) userData["topic"] = rcpt.Channel // Channel receiver should not know the ID of the message sender. delete(userData, "xfrom") msg := fcmv1.Message{ Topic: topic, Data: userData, } // We don't know the platform of the receiver, must provide payload for all platforms. msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config) msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, 0, config) // TODO: add webpush payload. messages = append(messages, &msg) // UID is not used in handling Topic pushes, but should keep the same count as messages. uids = append(uids, t.ZeroUid) } return messages, uids } func TenantChannelName(tenantID t.TenantID, channel string) string { if !tenantID.IsValid() || channel == "" { return channel } return "tenant-" + strconv.FormatInt(int64(tenantID), 10) + "-" + channel } // DevicesForUser loads device IDs of the given user. func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string { ddef, count, err := pushDevicesForUsers(tenantID, uid) if err != nil { logs.Warn.Println("fcm devices for user: db error", err) return nil } if count == 0 { return nil } devices := make([]string, count) for i, dd := range ddef[uid] { devices[i] = dd.DeviceId } return devices } func pushDevicesForUsers(tenantID t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenQueryInterface); ok { devices, count, err := tokens.GetAll(tenantID, uids...) if err != nil { return nil, 0, err } if count > 0 { return devices, count, nil } } return store.Devices.GetAll(tenantID, uids...) } // ChannelsForUser loads user's channel subscriptions with P permission. func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string { channels, err := store.Users.GetChannels(tenantID, uid) if err != nil { logs.Warn.Println("fcm channels for user: db error", err) return nil } return channels } func androidNotificationConfig(what, topic string, data map[string]string, config *configType) *fcmv1.AndroidConfig { timeToLive := strconv.Itoa(defaultTimeToLive) + "s" if config != nil && config.TimeToLive > 0 { timeToLive = strconv.Itoa(config.TimeToLive) + "s" } if what == push.ActRead { return &fcmv1.AndroidConfig{ Priority: string(common.AndroidPriorityNormal), Notification: nil, Ttl: timeToLive, } } _, videoCall := data["webrtc"] if videoCall { timeToLive = "0s" } // Sending priority. priority := string(common.AndroidPriorityHigh) ac := &fcmv1.AndroidConfig{ Priority: priority, Ttl: timeToLive, } // When this notification type is included and the app is not in the foreground // Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived. // See dicussion: https://github.com/firebase/quickstart-js/issues/71 if config.Android == nil || !config.Android.Enabled { return ac } body := config.Android.GetStringField(what, "Body") if body == "$content" { body = data["content"] } // Client-side display priority. priority = string(common.AndroidNotificationPriorityHigh) if videoCall { priority = string(common.AndroidNotificationPriorityMax) } ac.Notification = &fcmv1.AndroidNotification{ // Android uses Tag value to group notifications together: // show just one notification per topic. Tag: topic, NotificationPriority: priority, Visibility: string(common.AndroidVisibilityPrivate), TitleLocKey: config.Android.GetStringField(what, "TitleLocKey"), Title: config.Android.GetStringField(what, "Title"), BodyLocKey: config.Android.GetStringField(what, "BodyLocKey"), Body: body, Icon: config.Android.GetStringField(what, "Icon"), Color: config.Android.GetStringField(what, "Color"), ClickAction: config.Android.GetStringField(what, "ClickAction"), } return ac } func apnsShouldPresentAlert(what, callStatus, isSilent string, config *configType) bool { return config.Apns != nil && config.Apns.Enabled && what != push.ActRead && callStatus == "" && isSilent == "" } func apnsNotificationConfig(what, topic string, data map[string]string, unread int, config *configType) *fcmv1.ApnsConfig { callStatus := data["webrtc"] expires := time.Now().UTC().Add(time.Duration(defaultTimeToLive) * time.Second) if config.TimeToLive > 0 { expires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second) } bundleId := config.ApnsBundleID pushType := common.ApnsPushTypeAlert priority := 10 interruptionLevel := common.InterruptionLevelTimeSensitive if callStatus == "started" { // Send VOIP push only when a new call is started, otherwise send normal alert. interruptionLevel = common.InterruptionLevelCritical // FIXME: PushKit notifications do not work with the current FCM adapter. // Using normal pushes as a poor-man's replacement for VOIP pushes. // Uncomment the following two lines when FCM fixes its problem or when we switch to // a different adapter. // pushType = common.ApnsPushTypeVoip // bundleId += ".voip" expires = time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second) } else if what == push.ActRead { priority = 5 interruptionLevel = common.InterruptionLevelPassive pushType = common.ApnsPushTypeBackground } apsPayload := common.Aps{ Badge: unread, ContentAvailable: 1, MutableContent: 1, InterruptionLevel: interruptionLevel, Sound: "default", ThreadID: topic, } // Do not present alert for read notifications and video calls. if apnsShouldPresentAlert(what, callStatus, data["silent"], config) { body := config.Apns.GetStringField(what, "Body") if body == "$content" { body = data["content"] } apsPayload.Alert = &common.ApsAlert{ Action: config.Apns.GetStringField(what, "Action"), ActionLocKey: config.Apns.GetStringField(what, "ActionLocKey"), Body: body, LaunchImage: config.Apns.GetStringField(what, "LaunchImage"), LocKey: config.Apns.GetStringField(what, "LocKey"), Title: config.Apns.GetStringField(what, "Title"), Subtitle: config.Apns.GetStringField(what, "Subtitle"), TitleLocKey: config.Apns.GetStringField(what, "TitleLocKey"), SummaryArg: config.Apns.GetStringField(what, "SummaryArg"), SummaryArgCount: config.Apns.GetIntField(what, "SummaryArgCount"), } } payload, err := json.Marshal(map[string]any{"aps": apsPayload}) if err != nil { return nil } headers := map[string]string{ common.HeaderApnsExpiration: strconv.FormatInt(expires.Unix(), 10), common.HeaderApnsPriority: strconv.Itoa(priority), common.HeaderApnsTopic: bundleId, common.HeaderApnsCollapseID: topic, common.HeaderApnsPushType: string(pushType), } ac := &fcmv1.ApnsConfig{ Headers: headers, Payload: payload, } return ac }