Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
90 changes: 49 additions & 41 deletions async_firebase/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -255,48 +255,52 @@ 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).
:param bandwidth_constrained_ok: Allow delivery on constrained network (optional).
: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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
27 changes: 27 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
54 changes: 54 additions & 0 deletions tests/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading