push notifs: Implement APNs with new API.

And it works!

A couple of things still to do:

 * When a device token is no longer active, we'll get HTTP status 410.
   We should then remove the token from the database so we don't keep
   trying to push to it.  This is fairly urgent.

 * The library we're using has a nice asynchronous API, but this
   version doesn't use it.  This is OK now, but async will be
   essential at scale.
This commit is contained in:
Greg Price 2017-08-18 16:38:11 -07:00 committed by Tim Abbott
parent 35db1b2f11
commit 613d093d7d
3 changed files with 56 additions and 18 deletions

View File

@ -9,6 +9,8 @@ import time
import random import random
from typing import Any, Dict, List, Optional, SupportsInt, Text, Union, Type from typing import Any, Dict, List, Optional, SupportsInt, Text, Union, Type
from apns2.client import APNsClient
from apns2.payload import Payload as APNsPayload
from django.conf import settings from django.conf import settings
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -49,16 +51,42 @@ def hex_to_b64(data):
# Sending to APNs, for iOS # Sending to APNs, for iOS
# #
# `APNS_SANDBOX` should be a bool _apns_client = None # type: APNsClient
assert isinstance(settings.APNS_SANDBOX, bool)
def get_apns_client():
global _apns_client
if _apns_client is None:
# NB if called concurrently, this will make excess connections.
# That's a little sloppy, but harmless unless a server gets
# hammered with a ton of these all at once after startup.
_apns_client = APNsClient(credentials=settings.APNS_CERT_FILE,
use_sandbox=settings.APNS_SANDBOX)
return _apns_client
@statsd_increment("apple_push_notification") @statsd_increment("apple_push_notification")
def send_apple_push_notification(user_id, devices, **extra_data): def send_apple_push_notification(user_id, devices, payload_data):
# type: (int, List[DeviceToken], **Any) -> None # type: (int, List[DeviceToken], Dict[str, Any]) -> None
if not devices: if not devices:
return return
logging.warn("APNs unimplemented. Dropping notification for user %d with %d devices.", logging.info("APNs: Sending notification for user %d to %d devices",
user_id, len(devices)) user_id, len(devices))
payload = APNsPayload(**payload_data)
expiration = int(time.time() + 24 * 3600)
client = get_apns_client()
for device in devices:
# TODO obviously this should be made to actually use the async
stream_id = client.send_notification_async(
device.token, payload, topic='org.zulip.Zulip',
expiration=expiration)
result = client.get_notification_result(stream_id)
if result == 'Success':
logging.info("APNs: Success sending for user %d to device %s",
user_id, device.token)
else:
logging.warn("APNs: Failed to send for user %d to device %s: %s",
user_id, device.token, result)
# TODO delete token if status 410 (and timestamp isn't before
# the token we have)
# #
# Sending to GCM, for Android # Sending to GCM, for Android
@ -293,7 +321,13 @@ def get_apns_payload(message):
# type: (Message) -> Dict[str, Any] # type: (Message) -> Dict[str, Any]
return { return {
'alert': get_alert_from_message(message), 'alert': get_alert_from_message(message),
'message_ids': [message.id], # TODO: set badge count in a better way
'badge': 1,
'custom': {
'zulip': {
'message_ids': [message.id],
}
}
} }
def get_gcm_payload(user_profile, message): def get_gcm_payload(user_profile, message):
@ -369,10 +403,9 @@ def handle_push_notification(user_profile_id, missed_message):
apple_devices = list(PushDeviceToken.objects.filter(user=user_profile, apple_devices = list(PushDeviceToken.objects.filter(user=user_profile,
kind=PushDeviceToken.APNS)) kind=PushDeviceToken.APNS))
# TODO: set badge count in a better way
if apple_devices: if apple_devices:
send_apple_push_notification(user_profile.id, apple_devices, send_apple_push_notification(user_profile.id, apple_devices,
badge=1, zulip=apns_payload) apns_payload)
if android_devices: if android_devices:
send_android_push_notification(android_devices, gcm_payload) send_android_push_notification(android_devices, gcm_payload)

View File

@ -306,6 +306,7 @@ class HandlePushNotificationTest(PushNotificationTest):
mock.patch('zerver.lib.push_notifications.requests.request', mock.patch('zerver.lib.push_notifications.requests.request',
side_effect=self.bounce_request), \ side_effect=self.bounce_request), \
mock.patch('zerver.lib.push_notifications.gcm') as mock_gcm, \ mock.patch('zerver.lib.push_notifications.gcm') as mock_gcm, \
mock.patch('zerver.lib.push_notifications._apns_client') as mock_apns, \
mock.patch('logging.info') as mock_info, \ mock.patch('logging.info') as mock_info, \
mock.patch('logging.warn') as mock_warn: mock.patch('logging.warn') as mock_warn:
apns_devices = [ apns_devices = [
@ -320,10 +321,12 @@ class HandlePushNotificationTest(PushNotificationTest):
] ]
mock_gcm.json_request.return_value = { mock_gcm.json_request.return_value = {
'success': {gcm_devices[0][2]: message.id}} 'success': {gcm_devices[0][2]: message.id}}
mock_apns.get_notification_result.return_value = 'Success'
apn.handle_push_notification(self.user_profile.id, missed_message) apn.handle_push_notification(self.user_profile.id, missed_message)
mock_warn.assert_called_with( for _, _, token in apns_devices:
"APNs unimplemented. Dropping notification for user %d with %d devices.", mock_info.assert_any_call(
self.user_profile.id, len(apns_devices)) "APNs: Success sending for user %d to device %s",
self.user_profile.id, token)
for _, _, token in gcm_devices: for _, _, token in gcm_devices:
mock_info.assert_any_call( mock_info.assert_any_call(
"GCM: Sent %s as %s" % (token, message.id)) "GCM: Sent %s as %s" % (token, message.id))
@ -455,8 +458,7 @@ class HandlePushNotificationTest(PushNotificationTest):
apn.handle_push_notification(self.user_profile.id, missed_message) apn.handle_push_notification(self.user_profile.id, missed_message)
mock_send_apple.assert_called_with(self.user_profile.id, mock_send_apple.assert_called_with(self.user_profile.id,
apple_devices, apple_devices,
badge=1, {'apns': True})
zulip={'apns': True})
mock_send_android.assert_called_with(android_devices, mock_send_android.assert_called_with(android_devices,
{'gcm': True}) {'gcm': True})
@ -488,8 +490,13 @@ class TestGetAPNsPayload(PushNotificationTest):
message = self.get_message(Recipient.HUDDLE) message = self.get_message(Recipient.HUDDLE)
payload = apn.get_apns_payload(message) payload = apn.get_apns_payload(message)
expected = { expected = {
"alert": "New private group message from King Hamlet", 'alert': "New private group message from King Hamlet",
"message_ids": [message.id], 'badge': 1,
'custom': {
'zulip': {
'message_ids': [message.id],
}
}
} }
self.assertDictEqual(payload, expected) self.assertDictEqual(payload, expected)

View File

@ -102,9 +102,7 @@ def remote_server_notify_push(request, # type: HttpRequest
if android_devices: if android_devices:
send_android_push_notification(android_devices, gcm_payload, remote=True) send_android_push_notification(android_devices, gcm_payload, remote=True)
# TODO: set badge count in a better way
if apple_devices: if apple_devices:
send_apple_push_notification(user_id, apple_devices, send_apple_push_notification(user_id, apple_devices, apns_payload)
badge=1, zulip=apns_payload)
return json_success() return json_success()