diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index 17067f175..09f7daf87 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -36,6 +36,7 @@ class Message(object): android: An instance of ``messaging.AndroidConfig`` (optional). webpush: An instance of ``messaging.WebpushConfig`` (optional). apns: An instance of ``messaging.ApnsConfig`` (optional). + fcm_options: An instance of ``messaging.FcmOptions`` (optional). token: The registration token of the device to which the message should be sent (optional). topic: Name of the FCM topic to which the message should be sent (optional). Topic name may contain the ``/topics/`` prefix. @@ -43,12 +44,13 @@ class Message(object): """ def __init__(self, data=None, notification=None, android=None, webpush=None, apns=None, - token=None, topic=None, condition=None): + fcm_options=None, token=None, topic=None, condition=None): self.data = data self.notification = notification self.android = android self.webpush = webpush self.apns = apns + self.fcm_options = fcm_options self.token = token self.topic = topic self.condition = condition @@ -65,8 +67,10 @@ class MulticastMessage(object): android: An instance of ``messaging.AndroidConfig`` (optional). webpush: An instance of ``messaging.WebpushConfig`` (optional). apns: An instance of ``messaging.ApnsConfig`` (optional). + fcm_options: An instance of ``messaging.FcmOptions`` (optional). """ - def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None): + def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None, + fcm_options=None): _Validators.check_string_list('MulticastMessage.tokens', tokens) if len(tokens) > 100: raise ValueError('MulticastMessage.tokens must not contain more than 100 tokens.') @@ -76,6 +80,7 @@ def __init__(self, tokens, data=None, notification=None, android=None, webpush=N self.android = android self.webpush = webpush self.apns = apns + self.fcm_options = fcm_options class Notification(object): @@ -107,16 +112,18 @@ class AndroidConfig(object): data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. When specified, overrides any data fields set via ``Message.data``. notification: A ``messaging.AndroidNotification`` to be included in the message (optional). + fcm_options: A ``messaging.AndroidFcmOptions`` to be included in the message (optional). """ def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_package_name=None, - data=None, notification=None): + data=None, notification=None, fcm_options=None): self.collapse_key = collapse_key self.priority = priority self.ttl = ttl self.restricted_package_name = restricted_package_name self.data = data self.notification = notification + self.fcm_options = fcm_options class AndroidNotification(object): @@ -165,6 +172,18 @@ def __init__(self, title=None, body=None, icon=None, color=None, sound=None, tag self.channel_id = channel_id +class AndroidFcmOptions(object): + """Options for features provided by the FCM SDK for Android. + + Args: + analytics_label: contains additional options for features provided by the FCM Android SDK + (optional). + """ + + def __init__(self, analytics_label=None): + self.analytics_label = analytics_label + + class WebpushConfig(object): """Webpush-specific options that can be included in a message. @@ -279,14 +298,17 @@ class APNSConfig(object): Args: headers: A dictionary of headers (optional). payload: A ``messaging.APNSPayload`` to be included in the message (optional). + fcm_options: A ``messaging.APNSFcmOptions`` instance to be included in the message + (optional). .. _APNS Documentation: https://developer.apple.com/library/content/documentation\ /NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html """ - def __init__(self, headers=None, payload=None): + def __init__(self, headers=None, payload=None, fcm_options=None): self.headers = headers self.payload = payload + self.fcm_options = fcm_options class APNSPayload(object): @@ -387,6 +409,29 @@ def __init__(self, title=None, subtitle=None, body=None, loc_key=None, loc_args= self.launch_image = launch_image +class APNSFcmOptions(object): + """Options for features provided by the FCM SDK for iOS. + + Args: + analytics_label: contains additional options for features provided by the FCM iOS SDK + (optional). + """ + + def __init__(self, analytics_label=None): + self.analytics_label = analytics_label + + +class FcmOptions(object): + """Options for features provided by SDK. + + Args: + analytics_label: contains additional options to use across all platforms (optional). + """ + + def __init__(self, analytics_label=None): + self.analytics_label = analytics_label + + class _Validators(object): """A collection of data validation utilities. @@ -442,6 +487,14 @@ def check_string_list(cls, label, value): raise ValueError('{0} must not contain non-string values.'.format(label)) return value + @classmethod + def check_analytics_label(cls, label, value): + """Checks if the given value is a valid analytics label.""" + value = _Validators.check_string(label, value) + if value is not None and not re.match(r'^[a-zA-Z0-9-_.~%]{1,50}$', value): + raise ValueError('Malformed {}.'.format(label)) + return value + class MessageEncoder(json.JSONEncoder): """A custom JSONEncoder implementation for serializing Message instances into JSON.""" @@ -468,6 +521,7 @@ def encode_android(cls, android): 'restricted_package_name': _Validators.check_string( 'AndroidConfig.restricted_package_name', android.restricted_package_name), 'ttl': cls.encode_ttl(android.ttl), + 'fcm_options': cls.encode_android_fcm_options(android.fcm_options), } result = cls.remove_null_values(result) priority = result.get('priority') @@ -475,6 +529,21 @@ def encode_android(cls, android): raise ValueError('AndroidConfig.priority must be "high" or "normal".') return result + @classmethod + def encode_android_fcm_options(cls, fcm_options): + """Encodes a AndroidFcmOptions instance into a json.""" + if fcm_options is None: + return None + if not isinstance(fcm_options, AndroidFcmOptions): + raise ValueError('AndroidConfig.fcm_options must be an instance of ' + 'AndroidFcmOptions class.') + result = { + 'analytics_label': _Validators.check_analytics_label( + 'AndroidFcmOptions.analytics_label', fcm_options.analytics_label), + } + result = cls.remove_null_values(result) + return result + @classmethod def encode_ttl(cls, ttl): """Encodes a AndroidConfig TTL duration into a string.""" @@ -553,7 +622,7 @@ def encode_webpush(cls, webpush): 'headers': _Validators.check_string_dict( 'WebpushConfig.headers', webpush.headers), 'notification': cls.encode_webpush_notification(webpush.notification), - 'fcmOptions': cls.encode_webpush_fcm_options(webpush.fcm_options), + 'fcm_options': cls.encode_webpush_fcm_options(webpush.fcm_options), } return cls.remove_null_values(result) @@ -653,6 +722,7 @@ def encode_apns(cls, apns): 'headers': _Validators.check_string_dict( 'APNSConfig.headers', apns.headers), 'payload': cls.encode_apns_payload(apns.payload), + 'fcm_options': cls.encode_apns_fcm_options(apns.fcm_options), } return cls.remove_null_values(result) @@ -670,6 +740,20 @@ def encode_apns_payload(cls, payload): result[key] = value return cls.remove_null_values(result) + @classmethod + def encode_apns_fcm_options(cls, fcm_options): + """Encodes an APNSFcmOptions instance into JSON.""" + if fcm_options is None: + return None + if not isinstance(fcm_options, APNSFcmOptions): + raise ValueError('APNSConfig.fcm_options must be an instance of APNSFcmOptions class.') + result = { + 'analytics_label': _Validators.check_analytics_label( + 'APNSFcmOptions.analytics_label', fcm_options.analytics_label), + } + result = cls.remove_null_values(result) + return result + @classmethod def encode_aps(cls, aps): """Encodes an Aps instance into JSON.""" @@ -790,6 +874,7 @@ def default(self, obj): # pylint: disable=method-hidden 'token': _Validators.check_string('Message.token', obj.token, non_empty=True), 'topic': _Validators.check_string('Message.topic', obj.topic, non_empty=True), 'webpush': MessageEncoder.encode_webpush(obj.webpush), + 'fcm_options': MessageEncoder.encode_fcm_options(obj.fcm_options), } result['topic'] = MessageEncoder.sanitize_topic_name(result.get('topic')) result = MessageEncoder.remove_null_values(result) @@ -797,3 +882,17 @@ def default(self, obj): # pylint: disable=method-hidden if target_count != 1: raise ValueError('Exactly one of token, topic or condition must be specified.') return result + + @classmethod + def encode_fcm_options(cls, fcm_options): + """Encodes an FcmOptions instance into JSON.""" + if fcm_options is None: + return None + if not isinstance(fcm_options, FcmOptions): + raise ValueError('Message.fcm_options must be an instance of FcmOptions class.') + result = { + 'analytics_label': _Validators.check_analytics_label( + 'FcmOptions.analytics_label', fcm_options.analytics_label), + } + result = cls.remove_null_values(result) + return result diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 35d9e4ccd..ddaef19f0 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -33,8 +33,10 @@ __all__ = [ 'AndroidConfig', + 'AndroidFcmOptions', 'AndroidNotification', 'APNSConfig', + 'APNSFcmOptions', 'APNSPayload', 'ApiCallError', 'Aps', @@ -42,6 +44,7 @@ 'BatchResponse', 'CriticalSound', 'ErrorInfo', + 'FcmOptions', 'Message', 'MulticastMessage', 'Notification', @@ -61,12 +64,15 @@ AndroidConfig = _messaging_utils.AndroidConfig +AndroidFcmOptions = _messaging_utils.AndroidFcmOptions AndroidNotification = _messaging_utils.AndroidNotification APNSConfig = _messaging_utils.APNSConfig +APNSFcmOptions = _messaging_utils.APNSFcmOptions APNSPayload = _messaging_utils.APNSPayload Aps = _messaging_utils.Aps ApsAlert = _messaging_utils.ApsAlert CriticalSound = _messaging_utils.CriticalSound +FcmOptions = _messaging_utils.FcmOptions Message = _messaging_utils.Message MulticastMessage = _messaging_utils.MulticastMessage Notification = _messaging_utils.Notification @@ -145,6 +151,7 @@ def send_multicast(multicast_message, dry_run=False, app=None): android=multicast_message.android, webpush=multicast_message.webpush, apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, token=token ) for token in multicast_message.tokens] return _get_messaging_service(app).send_all(messages, dry_run) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index de940b591..878e1365b 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -120,6 +120,15 @@ def test_data_message(self): def test_prefixed_topic(self): check_encoding(messaging.Message(topic='/topics/topic'), {'topic': 'topic'}) + def test_fcm_options(self): + check_encoding( + messaging.Message( + topic='topic', fcm_options=messaging.FcmOptions('analytics_label_v1')), + {'topic': 'topic', 'fcm_options': {'analytics_label': 'analytics_label_v1'}}) + check_encoding( + messaging.Message(topic='topic', fcm_options=messaging.FcmOptions()), + {'topic': 'topic'}) + class TestNotificationEncoder(object): @@ -157,6 +166,47 @@ def test_notification_message(self): {'topic': 'topic', 'notification': {'title': 't'}}) +class TestFcmOptionEncoder(object): + + @pytest.mark.parametrize('label', [ + '!', + 'THIS_IS_LONGER_THAN_50_CHARACTERS_WHICH_IS_NOT_ALLOWED', + '', + ]) + def test_invalid_fcm_options(self, label): + with pytest.raises(ValueError) as excinfo: + check_encoding(messaging.Message( + topic='topic', + fcm_options=messaging.FcmOptions(label) + )) + expected = 'Malformed FcmOptions.analytics_label.' + assert str(excinfo.value) == expected + + def test_fcm_options(self): + check_encoding( + messaging.Message( + topic='topic', + fcm_options=messaging.FcmOptions(), + android=messaging.AndroidConfig(fcm_options=messaging.AndroidFcmOptions()), + apns=messaging.APNSConfig(fcm_options=messaging.APNSFcmOptions()) + ), + {'topic': 'topic'}) + check_encoding( + messaging.Message( + topic='topic', + fcm_options=messaging.FcmOptions('message-label'), + android=messaging.AndroidConfig( + fcm_options=messaging.AndroidFcmOptions('android-label')), + apns=messaging.APNSConfig(fcm_options=messaging.APNSFcmOptions('apns-label')) + ), + { + 'topic': 'topic', + 'fcm_options': {'analytics_label': 'message-label'}, + 'android': {'fcm_options': {'analytics_label': 'android-label'}}, + 'apns': {'fcm_options': {'analytics_label': 'apns-label'}}, + }) + + class TestAndroidConfigEncoder(object): @pytest.mark.parametrize('data', NON_OBJECT_ARGS) @@ -216,7 +266,8 @@ def test_android_config(self): restricted_package_name='package', priority='high', ttl=123, - data={'k1': 'v1', 'k2': 'v2'} + data={'k1': 'v1', 'k2': 'v2'}, + fcm_options=messaging.AndroidFcmOptions('analytics_label_v1') ) ) expected = { @@ -230,6 +281,9 @@ def test_android_config(self): 'k1': 'v1', 'k2': 'v2', }, + 'fcm_options': { + 'analytics_label': 'analytics_label_v1', + }, }, } check_encoding(msg, expected) @@ -484,7 +538,7 @@ def test_webpush_notification(self): expected = { 'topic': 'topic', 'webpush': { - 'fcmOptions': { + 'fcm_options': { 'link': 'https://example', }, }, @@ -714,7 +768,10 @@ def test_invalid_headers(self, data): def test_apns_config(self): msg = messaging.Message( topic='topic', - apns=messaging.APNSConfig(headers={'h1': 'v1', 'h2': 'v2'}) + apns=messaging.APNSConfig( + headers={'h1': 'v1', 'h2': 'v2'}, + fcm_options=messaging.APNSFcmOptions('analytics_label_v1') + ), ) expected = { 'topic': 'topic', @@ -723,6 +780,9 @@ def test_apns_config(self): 'h1': 'v1', 'h2': 'v2', }, + 'fcm_options': { + 'analytics_label': 'analytics_label_v1', + }, }, } check_encoding(msg, expected)