From 662edc2558447ba5d5ba8ae9c7f918c7e70dd459 Mon Sep 17 00:00:00 2001 From: Kevin Mehall Date: Mon, 9 Dec 2013 17:19:59 -0500 Subject: [PATCH] [manual] Backend support for Android GCM push notifications This adds a dependency on gcmclient: http://gcm-client.readthedocs.org/en/latest/gcmclient.html pip install gcm-client or apt-get install python-gcm-client (imported from commit 9f1fbf1f793e4a27baed85c6f1aa7a7b03106a10) --- puppet/zulip/manifests/app_frontend.pp | 2 ++ zerver/lib/actions.py | 44 +++++++++++++++++++++++--- zerver/lib/push_notifications.py | 43 +++++++++++++++++++++++++ zerver/tests.py | 30 ++++++++++++++++++ zerver/views/__init__.py | 9 ++++++ zproject/local_settings.py | 5 +++ zproject/settings.py | 1 + zproject/urls.py | 3 ++ 8 files changed, 132 insertions(+), 5 deletions(-) diff --git a/puppet/zulip/manifests/app_frontend.pp b/puppet/zulip/manifests/app_frontend.pp index 2bfdbb3ae1..82bbc9fbcc 100644 --- a/puppet/zulip/manifests/app_frontend.pp +++ b/puppet/zulip/manifests/app_frontend.pp @@ -54,6 +54,8 @@ class zulip::app_frontend { "python-diff-match-patch", # Needed for iOS "python-apns-client", + # Needed for Android push + "python-gcm-client", # Needed for avatar image resizing "python-imaging", # Needed for LDAP support diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 36a51634c7..bb5ee3499d 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -14,6 +14,7 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, get_stream_cache_key, to_dict_cache_key_id, is_super_user, \ UserActivityInterval, get_active_user_dicts_in_realm, RealmAlias, \ ScheduledJob +from zerver.lib.avatar import get_avatar_url from django.db import transaction, IntegrityError from django.db.models import F, Q @@ -44,7 +45,8 @@ from zerver.lib.utils import log_statsd_event, statsd from zerver.lib.html_diff import highlight_html_differences from zerver.lib.alert_words import user_alert_words, add_user_alert_words, \ remove_user_alert_words, set_user_alert_words -from zerver.lib.push_notifications import num_push_devices_for_user, send_apple_push_notification +from zerver.lib.push_notifications import num_push_devices_for_user, \ + send_apple_push_notification, send_android_push_notification from zerver import tornado_callbacks @@ -2020,7 +2022,10 @@ def handle_push_notification(user_profile_id, missed_message): return sender_str = message.sender.full_name - if user_profile.enable_offline_push_notifications and num_push_devices_for_user(user_profile): + apple = num_push_devices_for_user(user_profile, kind=PushDeviceToken.APNS) + android = num_push_devices_for_user(user_profile, kind=PushDeviceToken.GCM) + + if apple or android: #TODO: set badge count in a better way # Determine what alert string to display based on the missed messages if message.recipient.type == Recipient.HUDDLE: @@ -2031,12 +2036,41 @@ def handle_push_notification(user_profile_id, missed_message): alert = "New mention from %s" % (sender_str,) else: alert = "New Zulip mentions and private messages from %s" % (sender_str,) - extra_data = {'message_ids': [message.id]} - send_apple_push_notification(user_profile, alert, badge=1, zulip=extra_data) + if apple: + apple_extra_data = {'message_ids': [message.id]} + send_apple_push_notification(user_profile, alert, badge=1, zulip=apple_extra_data) + + if android: + content = message.content + content_truncated = (len(content) > 200) + if content_truncated: + content = content[:200] + "..." + + android_data = { + 'user': user_profile.email, + 'event': 'message', + 'alert': alert, + 'zulip_message_id': message.id, # message_id is reserved for CCS + 'time': datetime_to_timestamp(message.pub_date), + 'content': content, + 'content_truncated': content_truncated, + 'sender_email': message.sender.email, + 'sender_full_name': message.sender.full_name, + 'sender_avatar_url': get_avatar_url(message.sender.avatar_source, message.sender.email), + } + + if message.recipient.type == Recipient.STREAM: + android_data['recipient_type'] = "stream" + android_data['stream'] = get_display_recipient(message.recipient) + android_data['topic'] = message.subject + elif message.recipient.type in (Recipient.HUDDLE, Recipient.PERSONAL): + android_data['recipient_type'] = "private" + + send_android_push_notification(user_profile, android_data) + except UserMessage.DoesNotExist: logging.error("Could not find UserMessage with message_id %s" %(missed_message['message_id'],)) - return def handle_missedmessage_emails(user_profile_id, missed_email_events): message_ids = [event.get('message_id') for event in missed_email_events] diff --git a/zerver/lib/push_notifications.py b/zerver/lib/push_notifications.py index 95dab8e8ff..dea2d1eb74 100644 --- a/zerver/lib/push_notifications.py +++ b/zerver/lib/push_notifications.py @@ -5,6 +5,7 @@ from zerver.lib.timestamp import timestamp_to_datetime from zerver.decorator import statsd_increment from apnsclient import Session, Message, APNs +import gcmclient from django.conf import settings @@ -37,6 +38,7 @@ def hex_to_b64(data): def send_apple_push_notification(user, alert, **extra_data): if not connection: logging.error("Attempting to send push notification, but no connection was found. This may be because we could not find the APNS Certificate file.") + return tokens = [b64_to_hex(device.token) for device in PushDeviceToken.objects.filter(user=user, kind=PushDeviceToken.APNS)] @@ -74,3 +76,44 @@ def check_apns_feedback(): PushDeviceToken.objects.filter(token=hex_to_b64(token), last_updates__lt=since_date, type=PushDeviceToken.APNS).delete() logging.info("Finished checking feedback for stale tokens") + + +if settings.ANDROID_GCM_API_KEY: + gcm = gcmclient.GCM(settings.ANDROID_GCM_API_KEY) +else: + gcm = None + +@statsd_increment("android_push_notification") +def send_android_push_notification(user, data): + if not gcm: + logging.error("Attempting to send a GCM push notification, but no API key was configured") + return + + reg_ids = [device.token for device in + PushDeviceToken.objects.filter(user=user, kind=PushDeviceToken.GCM)] + + msg = gcmclient.JSONMessage(reg_ids, data) + res = gcm.send(msg) + + for reg_id, msg_id in res.success.items(): + logging.info("GCM: Sent %s as %s" % (reg_id, msg_id)) + + for reg_id, new_reg_id in res.canonical.items(): + logging.info("GCM: Updating registration %s with %s" % (reg_id, new_reg_id)) + + device = PushDeviceToken.objects.get(token=reg_id, kind=PushDeviceToken.GCM) + device.token = new_reg_id + device.save(update_fields=['token']) + + for reg_id in res.not_registered: + logging.info("GCM: Removing %s" % (reg_id,)) + + device = PushDeviceToken.objects.get(token=reg_id, kind=PushDeviceToken.GCM) + device.delete() + + for reg_id, err_code in res.failed.items(): + logging.warning("GCM: Delivery to %s failed: %s" % (reg_id, err_code)) + + if res.needs_retry(): + # TODO + logging.warning("GCM: delivery needs a retry but ignoring") diff --git a/zerver/tests.py b/zerver/tests.py index 076039aaa1..d144faf728 100644 --- a/zerver/tests.py +++ b/zerver/tests.py @@ -4798,6 +4798,36 @@ class APNSTokenTests(AuthedTestCase): result = self.client.delete('/json/users/me/apns_device_token', urllib.urlencode({'token': token})) self.assert_json_success(result) +class GCMTokenTests(AuthedTestCase): + def test_add_token(self): + email = "cordelia@zulip.com" + self.login(email) + + result = self.client.post('/json/users/me/apns_device_token', {'token': "test_token"}) + self.assert_json_success(result) + + def test_delete_token(self): + email = "cordelia@zulip.com" + self.login(email) + + token = "test_token" + result = self.client.post('/json/users/me/android_gcm_reg_id', {'token':token}) + self.assert_json_success(result) + + result = self.client.delete('/json/users/me/android_gcm_reg_id', urllib.urlencode({'token': token})) + self.assert_json_success(result) + + def test_change_user(self): + token = "test_token" + + self.login("cordelia@zulip.com") + result = self.client.post('/json/users/me/android_gcm_reg_id', {'token':token}) + self.assert_json_success(result) + + self.login("hamlet@zulip.com") + result = self.client.post('/json/users/me/android_gcm_reg_id', {'token':token}) + self.assert_json_success(result) + def full_test_name(test): test_class = test.__class__.__name__ test_method = test._testMethodName diff --git a/zerver/views/__init__.py b/zerver/views/__init__.py index a3deade50e..6ec49acf1d 100644 --- a/zerver/views/__init__.py +++ b/zerver/views/__init__.py @@ -2397,6 +2397,10 @@ def add_push_device_token(request, user_profile, token, kind): def add_apns_device_token(request, user_profile, token=REQ): return add_push_device_token(request, user_profile, token, PushDeviceToken.APNS) +@has_request_variables +def add_android_reg_id(request, user_profile, token=REQ): + return add_push_device_token(request, user_profile, token, PushDeviceToken.GCM) + def remove_push_device_token(request, user_profile, token, kind): if token == '' or len(token) > 4096: return json_error('Empty or invalid length token') @@ -2413,6 +2417,11 @@ def remove_push_device_token(request, user_profile, token, kind): def remove_apns_device_token(request, user_profile, token=REQ): return remove_push_device_token(request, user_profile, token, PushDeviceToken.APNS) +@has_request_variables +def remove_android_reg_id(request, user_profile, token=REQ): + return remove_push_device_token(request, user_profile, token, PushDeviceToken.GCM) + + def generate_204(request): return HttpResponse(content=None, status=204) diff --git a/zproject/local_settings.py b/zproject/local_settings.py index 52391b8874..bb876519dd 100644 --- a/zproject/local_settings.py +++ b/zproject/local_settings.py @@ -136,6 +136,11 @@ else: APNS_FEEDBACK = "feedback_sandbox" APNS_CERT_FILE = "/etc/ssl/django-private/apns-dev.pem" +if DEPLOYED: + ANDROID_GCM_API_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +else: + ANDROID_GCM_API_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + # Administrator domain for this install ADMIN_DOMAIN = "zulip.com" diff --git a/zproject/settings.py b/zproject/settings.py index cec06e814f..206964f45d 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -258,6 +258,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '', 'NAGIOS_STAGING_SEND_BOT': None, 'NAGIOS_STAGING_RECEIVE_BOT': None, 'APNS_CERT_FILE': None, + 'ANDROID_GCM_API_KEY': None, 'INITIAL_PASSWORD_SALT': None, 'FEEDBACK_BOT': 'feedback@zulip.com', 'FEEDBACK_BOT_NAME': 'Zulip Feedback Bot', diff --git a/zproject/urls.py b/zproject/urls.py index be718b23b2..1f85e9bc6a 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -217,6 +217,9 @@ v1_api_and_json_patterns = patterns('zerver.views', url(r'^users/me/apns_device_token$', 'rest_dispatch', {'POST' : 'add_apns_device_token', 'DELETE': 'remove_apns_device_token'}), + url(r'^users/me/android_gcm_reg_id$', 'rest_dispatch', + {'POST': 'add_android_reg_id', + 'DELETE': 'remove_android_reg_id'}), url(r'^users/(?P.*)/reactivate$', 'rest_dispatch', {'POST': 'reactivate_user_backend'}), url(r'^users/(?P.*)$', 'rest_dispatch',