Skip to content
Open
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
1 change: 1 addition & 0 deletions ably/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ably.rest.push import Push
from ably.rest.rest import AblyRest
from ably.types.annotation import Annotation, AnnotationAction
from ably.types.apnsbroadcast import PushApnsBroadcast
from ably.types.capability import Capability
from ably.types.channelmode import ChannelMode
from ably.types.channeloptions import ChannelOptions
Expand Down
100 changes: 100 additions & 0 deletions ably/rest/push.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from typing import Optional
from urllib import parse

from ably.http.paginatedresult import PaginatedResult, format_params
from ably.types.apnsbroadcast import PushApnsBroadcast
from ably.types.channelsubscription import (
PushChannelSubscription,
channel_subscriptions_response_processor,
channels_response_processor,
)
from ably.types.device import DeviceDetails, device_details_response_processor
from ably.util.exceptions import AblyException


class Push:
Expand All @@ -26,6 +29,7 @@ def __init__(self, ably):
self.__ably = ably
self.__device_registrations = PushDeviceRegistrations(ably)
self.__channel_subscriptions = PushChannelSubscriptions(ably)
self.__live_activity = PushLiveActivity(ably)

@property
def ably(self):
Expand All @@ -39,6 +43,12 @@ def device_registrations(self):
def channel_subscriptions(self):
return self.__channel_subscriptions

@property
def live_activity(self):
"""Controls iOS Live Activities over an APNs broadcast channel created
with `create_apns_broadcast`. Experimental."""
return self.__live_activity

async def publish(self, recipient: dict, data: dict, timeout: Optional[float] = None):
"""Publish a push notification to a single device.

Expand All @@ -62,6 +72,96 @@ async def publish(self, recipient: dict, data: dict, timeout: Optional[float] =
body.update({'recipient': recipient})
await self.ably.http.post('/push/publish', body=body, timeout=timeout)

async def create_apns_broadcast(self, message_storage_policy: int) -> PushApnsBroadcast:
"""Creates an APNs broadcast channel for use with an iOS Live Activity.
Call once before starting the Live Activity and persist the returned
ids for the session.

Experimental: this is a preview feature and may change in a future
non-major release.

:Parameters:
- `message_storage_policy`: 1 to cache the last update so that devices
subscribing late receive the current content state, 0 to disable caching
"""
body = {'messageStoragePolicy': message_storage_policy}
response = await self.ably.http.post('/push/apnsBroadcastChannels', body=body)
return PushApnsBroadcast.from_dict(response.to_native())


class PushLiveActivity:
"""Controls the lifecycle of an iOS Live Activity over an APNs broadcast
channel created with `PushAdmin.create_apns_broadcast`.

Experimental: this is a preview feature and may change in a future
non-major release.
"""

def __init__(self, ably):
self.__ably = ably

@property
def ably(self):
return self.__ably

async def start(self, recipient: dict, apns_broadcast: str, apns: dict, headers: Optional[dict] = None):
"""Sends a push-to-start notification, starting a new Live Activity on
each targeted device.

:Parameters:
- `recipient`: either `{'channels': [...]}`, targeting the devices
subscribed to any of those channels, or `{'deviceId': ...}`,
targeting a single device
- `apns_broadcast`: the id returned by `create_apns_broadcast`
- `apns`: the APNs Live Activity start payload, passed through as-is
- `headers`: optional APNs delivery headers, such as `apns-priority`
and `apns-expiration`
"""
channels = recipient.get('channels')
device_id = recipient.get('deviceId')
if bool(channels) == bool(device_id):
raise AblyException(
'PushLiveActivity.start() requires exactly one of recipient channels or deviceId', 400, 40000)

body = {'apns': apns}
if channels:
body['channels'] = channels
else:
body['deviceId'] = device_id
await self._post(apns_broadcast, 'start', body, headers)

async def update(self, apns_broadcast: str, apns: dict, headers: Optional[dict] = None):
"""Sends a content-state update to every device with an active Live
Activity on the broadcast channel. APNs fans the single push out to
the subscribed devices.

:Parameters:
- `apns_broadcast`: the id returned by `create_apns_broadcast`
- `apns`: the APNs Live Activity update payload, passed through as-is
- `headers`: optional APNs delivery headers, such as `apns-priority`
and `apns-expiration`
"""
await self._post(apns_broadcast, 'broadcast', {'apns': apns}, headers)

async def end(self, apns_broadcast: str, apns: dict, headers: Optional[dict] = None):
"""Ends the Live Activity on every subscribed device and cleans up the
APNs channel. The broadcast id is no longer valid afterwards.

:Parameters:
- `apns_broadcast`: the id returned by `create_apns_broadcast`
- `apns`: the APNs Live Activity end payload, passed through as-is
- `headers`: optional APNs delivery headers, such as `apns-priority`
and `apns-expiration`
"""
await self._post(apns_broadcast, 'end', {'apns': apns}, headers)

async def _post(self, apns_broadcast: str, action: str, body: dict, headers: Optional[dict]):
# The APNs delivery headers travel in the request body, not as HTTP headers
if headers:
body['headers'] = headers
path = f'/push/apnsBroadcastChannels/{parse.quote(apns_broadcast, safe="")}/{action}'
await self.ably.http.post(path, body=body)


class PushDeviceRegistrations:

Expand Down
23 changes: 23 additions & 0 deletions ably/types/apnsbroadcast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class PushApnsBroadcast:
"""An APNs broadcast channel created for an iOS Live Activity.

Experimental: this is a preview feature and may change in a future non-major release.
"""

def __init__(self, id, apns_channel_id):
self.__id = id
self.__apns_channel_id = apns_channel_id

@property
def id(self):
"""The opaque Ably broadcast id, passed to the `push.admin.live_activity` methods."""
return self.__id

@property
def apns_channel_id(self):
"""The Apple-assigned channel id."""
return self.__apns_channel_id

@classmethod
def from_dict(cls, obj):
return cls(id=obj.get('id'), apns_channel_id=obj.get('apnsChannelId'))
40 changes: 40 additions & 0 deletions test/unit/push_live_activity_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import httpx
import pytest

from ably import AblyRest
from ably.types.testoptions import TestOptions
from ably.util.exceptions import AblyException


class RecordingTransport(httpx.AsyncBaseTransport):
def __init__(self):
self.requests = []

async def handle_async_request(self, request):
self.requests.append(request)
return httpx.Response(200, json={})


# RSH1e1a: the recipient must name exactly one of channels and deviceId,
# and is rejected before any request is made
@pytest.mark.parametrize('recipient', [
{},
{'channels': []},
{'channels': ['a'], 'deviceId': 'device-1'},
])
async def test_live_activity_start_rejects_invalid_recipient(recipient):
transport = RecordingTransport()
ably = AblyRest(key='appId.keyId:keySecret', _test_options=TestOptions(http_transport=transport))
try:
with pytest.raises(AblyException) as excinfo:
await ably.push.admin.live_activity.start(
recipient=recipient,
apns_broadcast='broadcast-1',
apns={'aps': {'event': 'start'}},
)
finally:
await ably.close()

assert excinfo.value.code == 40000
assert excinfo.value.status_code == 400
assert transport.requests == []
110 changes: 110 additions & 0 deletions test/uts/rest/unit/push/push_admin_apns_broadcast_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Derived from uts/rest/unit/push/push_admin_apns_broadcast.md in ably/specification.

Spec points: RSH1, RSH1d
"""

import msgpack
import pytest

from ably.types.apnsbroadcast import PushApnsBroadcast
from ably.util.exceptions import AblyException
from test.uts.helpers.client import rest_client
from test.uts.helpers.mock_http import MockHttpClient

BROADCAST_RESPONSE = {'id': 'broadcast-1', 'apnsChannelId': 'apns-1'}


def capture_and_respond(captured_requests, status=201, body=None):
def on_request(request):
captured_requests.append(request)
request.respond_with(status, body if body is not None else BROADCAST_RESPONSE)

return on_request


# UTS: rest/unit/RSH1d/create-apns-broadcast-post-0
async def test_rsh1d_create_apns_broadcast_post():
captured_requests = []
mock_http = MockHttpClient(
on_connection_attempt=lambda conn: conn.respond_with_success(),
on_request=capture_and_respond(captured_requests),
)
client = rest_client(mock_http)

await client.push.admin.create_apns_broadcast(message_storage_policy=1)

assert len(captured_requests) == 1

request = captured_requests[0]
assert request.method == 'POST'
assert request.url.path == '/push/apnsBroadcastChannels'


# UTS: rest/unit/RSH1d/message-storage-policy-1
async def test_rsh1d_message_storage_policy():
captured_requests = []
mock_http = MockHttpClient(
on_connection_attempt=lambda conn: conn.respond_with_success(),
on_request=capture_and_respond(captured_requests),
)
client = rest_client(mock_http)

await client.push.admin.create_apns_broadcast(message_storage_policy=0)

assert len(captured_requests) == 1

body = msgpack.unpackb(captured_requests[0].body)
assert body['messageStoragePolicy'] == 0


# UTS: rest/unit/RSH1d/returns-ids-2
async def test_rsh1d_returns_ids():
mock_http = MockHttpClient(
on_connection_attempt=lambda conn: conn.respond_with_success(),
on_request=lambda request: request.respond_with(201, {
'id': 'broadcast-xyz',
'apnsChannelId': 'apple-channel-abc',
}),
)
client = rest_client(mock_http)

result = await client.push.admin.create_apns_broadcast(message_storage_policy=1)

assert isinstance(result, PushApnsBroadcast)
assert result.id == 'broadcast-xyz'
assert result.apns_channel_id == 'apple-channel-abc'


# UTS: rest/unit/RSH1d/auth-header-3
async def test_rsh1d_auth_header():
captured_requests = []
mock_http = MockHttpClient(
on_connection_attempt=lambda conn: conn.respond_with_success(),
on_request=capture_and_respond(captured_requests),
)
client = rest_client(mock_http)

await client.push.admin.create_apns_broadcast(message_storage_policy=1)

assert len(captured_requests) == 1
assert captured_requests[0].headers['Authorization'].startswith('Basic ')


# UTS: rest/unit/RSH1d/server-error-4
async def test_rsh1d_server_error():
mock_http = MockHttpClient(
on_connection_attempt=lambda conn: conn.respond_with_success(),
on_request=lambda request: request.respond_with(400, {
'error': {
'code': 40000,
'statusCode': 400,
'message': 'Invalid request',
},
}),
)
client = rest_client(mock_http)

with pytest.raises(AblyException) as excinfo:
await client.push.admin.create_apns_broadcast(message_storage_policy=1)

assert excinfo.value.code == 40000
Loading
Loading