diff --git a/CHANGES.md b/CHANGES.md index 6e20b10..ff37be6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Changelog +## 6.1.0 +* ``AndroidConfig.build()`` and ``APNSConfig.build()`` now support data-only FCM messages. When no notification/alert fields are provided, the builder omits the ``notification`` (Android) or ``alert`` (APNS) from the payload, producing a clean data-only message. +* ``AndroidConfig.build()`` ``visibility`` parameter default changed from ``Visibility.PRIVATE`` to ``None``. FCM applies ``PRIVATE`` server-side when omitted, so wire behavior is unchanged. + ## 6.0.2 * [FIX] Correct FCM wire-format for ``AndroidNotification`` enum fields to match the official ``firebase-admin-python`` SDK: * ``visibility``: send ``"PRIVATE"`` instead of ``"VISIBILITY_PRIVATE"`` (fixes ``InvalidArgumentError`` from FCM API). diff --git a/README.md b/README.md index 7f4f9b5..84a19a4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,17 @@ android_config = AndroidConfig.build( ) ``` +To send a **data-only** message (no notification), simply omit all notification fields: + +```python +android_config = AndroidConfig.build( + priority="high", + ttl=2419200, + collapse_key="push", + data={"discount": "15%", "key_1": "value_1"}, +) +``` + New in v6.0: ``image``, ``ticker``, ``sticky``, ``event_timestamp``, ``local_only``, ``notification_priority``, ``vibrate_timings_millis``, ``default_vibrate_timings``, ``default_sound``, ``light_settings``, ``default_light_settings``, ``fcm_options``, ``direct_boot_ok``, ``bandwidth_constrained_ok``, ``restricted_satellite_ok``. ### iOS (APNs) @@ -107,6 +118,20 @@ apns_config = APNSConfig.build( ) ``` +To send a **data-only** APNS message, omit all alert fields: + +```python +apns_config = APNSConfig.build( + priority="high", + ttl=2419200, + collapse_key="push", + badge=0, + category="test-category", + content_available=True, + custom_data={"key_1": "value_1"}, +) +``` + New in v6.0: ``subtitle``, ``sound`` as ``CriticalSound``, ``fcm_options``, ``live_activity_token``. ### Web Push diff --git a/async_firebase/messages.py b/async_firebase/messages.py index 0c24f08..c4b9abd 100644 --- a/async_firebase/messages.py +++ b/async_firebase/messages.py @@ -203,7 +203,7 @@ def build( light_settings: t.Optional["LightSettings"] = None, default_light_settings: t.Optional[bool] = None, notification_count: t.Optional[int] = None, - visibility: "Visibility" = Visibility.PRIVATE, + visibility: t.Optional["Visibility"] = None, proxy: t.Optional["NotificationProxy"] = None, fcm_options: t.Optional["AndroidFCMOptions"] = None, direct_boot_ok: t.Optional[bool] = None, @@ -255,7 +255,7 @@ def build( :param notification_count: The number of items in notification. May be displayed as a badge count for launchers that support badging. If zero or unspecified, systems that support badging use the default, which is to increment a number displayed on the long-press menu each time a new notification arrives (optional). - :param visibility: set the visibility of the notification. The default level, VISIBILITY_PRIVATE. + :param visibility: set the visibility of the notification (optional). If not specified, FCM defaults to PRIVATE. :param proxy: set the proxy behaviour. The default behaviour is set to None. :param fcm_options: Android-specific FCM options (optional). :param direct_boot_ok: Allow delivery in direct boot mode (optional). @@ -263,40 +263,44 @@ def build( :param restricted_satellite_ok: Allow delivery on restricted satellite network (optional). :return: an instance of ``messages.AndroidConfig`` to be included in the resulting payload. """ + notification: t.Optional[AndroidNotification] = AndroidNotification( + title=title, + body=body, + icon=icon, + color=color, + sound=sound, + tag=tag, + click_action=click_action, + body_loc_key=body_loc_key, + body_loc_args=body_loc_args or [], + title_loc_key=title_loc_key, + title_loc_args=title_loc_args or [], + channel_id=channel_id, + image=image, + ticker=ticker, + sticky=sticky, + event_timestamp=event_timestamp, + local_only=local_only, + priority=notification_priority, + vibrate_timings_millis=vibrate_timings_millis, + default_vibrate_timings=default_vibrate_timings, + default_sound=default_sound, + light_settings=light_settings, + default_light_settings=default_light_settings, + notification_count=notification_count, + visibility=visibility, + proxy=proxy, + ) + if notification == AndroidNotification(): + notification = None + return cls( collapse_key=collapse_key, priority=priority, ttl=f"{int(ttl.total_seconds()) if isinstance(ttl, timedelta) else ttl}s", restricted_package_name=restricted_package_name, data={str(key): "null" if value is None else str(value) for key, value in data.items()} if data else {}, - notification=AndroidNotification( - title=title, - body=body, - icon=icon, - color=color, - sound=sound, - tag=tag, - click_action=click_action, - body_loc_key=body_loc_key, - body_loc_args=body_loc_args or [], - title_loc_key=title_loc_key, - title_loc_args=title_loc_args or [], - channel_id=channel_id, - image=image, - ticker=ticker, - sticky=sticky, - event_timestamp=event_timestamp, - local_only=local_only, - priority=notification_priority, - vibrate_timings_millis=vibrate_timings_millis, - default_vibrate_timings=default_vibrate_timings, - default_sound=default_sound, - light_settings=light_settings, - default_light_settings=default_light_settings, - notification_count=notification_count, - visibility=visibility, - proxy=proxy, - ), + notification=notification, fcm_options=fcm_options, direct_boot_ok=direct_boot_ok, bandwidth_constrained_ok=bandwidth_constrained_ok, @@ -499,21 +503,25 @@ def build( if collapse_key: apns_headers["apns-collapse-id"] = str(collapse_key) + aps_alert: t.Optional[ApsAlert] = ApsAlert( + title=title, + subtitle=subtitle, + body=alert, + loc_key=loc_key, + loc_args=loc_args or [], + title_loc_key=title_loc_key, + title_loc_args=title_loc_args or [], + action_loc_key=action_loc_key, + launch_image=launch_image, + ) + if aps_alert == ApsAlert(): + aps_alert = None + return cls( headers=apns_headers, payload=APNSPayload( aps=Aps( - alert=ApsAlert( - title=title, - subtitle=subtitle, - body=alert, - loc_key=loc_key, - loc_args=loc_args or [], - title_loc_key=title_loc_key, - title_loc_args=title_loc_args or [], - action_loc_key=action_loc_key, - launch_image=launch_image, - ), + alert=aps_alert, badge=badge, sound="default" if alert and sound is None else sound, category=category, diff --git a/pyproject.toml b/pyproject.toml index 4de80f2..c451917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "async-firebase" -version = "6.0.2" +version = "6.1.0" description = "Async Firebase Client - a Python asyncio client to interact with Firebase Cloud Messaging in an easy way." license = "MIT" authors = [ diff --git a/tests/test_client.py b/tests/test_client.py index 9a6ad44..a2e9d3f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -563,6 +563,33 @@ def test_serialize_message(message, exp_push_notification): assert push_notification == exp_push_notification +def test_serialize_data_only_android_message(): + """Data-only AndroidConfig.build() should serialize without notification key.""" + message = Message( + token="token-1", + android=AndroidConfig.build( + priority="high", + ttl=2419200, + collapse_key="NEW_MESSAGE", + data={"silent": "true", "bundle": "hj_groups"}, + ), + ) + result = serialize_message(message) + assert result == { + "message": { + "token": "token-1", + "android": { + "collapse_key": "NEW_MESSAGE", + "priority": "high", + "ttl": "2419200s", + "data": {"silent": "true", "bundle": "hj_groups"}, + }, + }, + "validate_only": False, + } + assert "notification" not in result["message"]["android"] + + def test_build_webpush_config(fake_async_fcm_client_w_creds): webpush_config = fake_async_fcm_client_w_creds.build_webpush_config( data={"attr_1": "value_1", "attr_2": "value_2"}, diff --git a/tests/test_messages.py b/tests/test_messages.py index 7b2599d..73173b7 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -123,6 +123,60 @@ def test_apns_config_build_with_new_fields(freezer): assert config.live_activity_token == "live-token-123" +def test_android_config_build_data_only(): + """AndroidConfig.build() should omit notification when no notification fields are provided.""" + config = AndroidConfig.build( + priority="high", + ttl=2419200, + collapse_key="NEW_MESSAGE", + data={"key": "value"}, + ) + assert config.notification is None + assert config.collapse_key == "NEW_MESSAGE" + assert config.priority == "high" + assert config.ttl == "2419200s" + assert config.data == {"key": "value"} + + +def test_android_config_build_with_single_notification_field(): + """AndroidConfig.build() should create notification when any notification field is set.""" + config = AndroidConfig.build( + priority="high", + tag="MY_TAG", + ) + assert config.notification is not None + assert config.notification.tag == "MY_TAG" + + +def test_apns_config_build_data_only(freezer): + """APNSConfig.build() should omit alert when no alert fields are provided.""" + config = APNSConfig.build( + priority="high", + ttl=2419200, + collapse_key="NEW_MESSAGE", + badge=0, + category="NEW_MESSAGE_CATEGORY", + thread_id="NEW_MESSAGE", + custom_data={"bundle": "hj_groups"}, + ) + assert config.payload.aps.alert is None + assert config.payload.aps.badge == 0 + assert config.payload.aps.category == "NEW_MESSAGE_CATEGORY" + assert config.payload.aps.thread_id == "NEW_MESSAGE" + assert config.payload.aps.custom_data == {"bundle": "hj_groups"} + assert config.payload.aps.sound is None + + +def test_apns_config_build_with_single_alert_field(freezer): + """APNSConfig.build() should create alert when any alert field is set.""" + config = APNSConfig.build( + priority="high", + title="Hello", + ) + assert config.payload.aps.alert is not None + assert config.payload.aps.alert.title == "Hello" + + def test_webpush_config_build_vibrate_list(): """WebpushConfig.build() should accept vibrate as a list of ints.""" config = WebpushConfig.build(