push notifications: Add support for removing GCM push notifications.

This uses the recently introduced active_mobile_push_notification
flag; messages that have had a mobile push notification sent will have
a removal push notification sent as soon as they are marked as read.

Note that this feature is behind a setting,
SEND_REMOVE_PUSH_NOTIFICATIONS, since the notification format is not
supported by the mobile apps yet, and we want to give a grace period
before we start sending notifications that appear as (null) to
clients.  But the tracking logic to maintain the set of message IDs
with an active push notification runs unconditionally.

This is designed with at-least-once semantics; so mobile clients need
to handle the possibility that they receive duplicat requests to
remove a push notification.

We reuse the existing missedmessage_mobile_notifications queue
processor for the work, to avoid materially impacting the latency of
marking messages as read.

Fixes #7459, though we'll need to open a follow-up issue for
using these data on iOS.
This commit is contained in:
Tim Abbott 2018-08-01 16:29:06 -07:00
parent cc5c8fc022
commit da8f4bc0e9
8 changed files with 127 additions and 6 deletions

View File

@ -3549,11 +3549,19 @@ def do_update_pointer(user_profile: UserProfile, client: Client,
# that will mark as read any messages up until the pointer # that will mark as read any messages up until the pointer
# move; we expect to remove this feature entirely before long, # move; we expect to remove this feature entirely before long,
# when we drop support for the old Android app entirely. # when we drop support for the old Android app entirely.
app_message_ids = UserMessage.objects.filter(
user_profile=user_profile,
flags=UserMessage.flags.active_mobile_push_notification,
message__id__gt=prev_pointer,
message__id__lte=pointer).extra(where=[
UserMessage.where_unread(),
]).values_list("message_id", flat=True)
UserMessage.objects.filter(user_profile=user_profile, UserMessage.objects.filter(user_profile=user_profile,
message__id__gt=prev_pointer, message__id__gt=prev_pointer,
message__id__lte=pointer).extra( message__id__lte=pointer).extra(where=[UserMessage.where_unread()]) \
where=[UserMessage.where_unread()]).update( .update(flags=F('flags').bitor(UserMessage.flags.read))
flags=F('flags').bitor(UserMessage.flags.read)) do_clear_mobile_push_notifications_for_ids(user_profile, app_message_ids)
event = dict(type='pointer', pointer=pointer) event = dict(type='pointer', pointer=pointer)
send_event(event, [user_profile.id]) send_event(event, [user_profile.id])
@ -3581,6 +3589,13 @@ def do_mark_all_as_read(user_profile: UserProfile, client: Client) -> int:
send_event(event, [user_profile.id]) send_event(event, [user_profile.id])
statsd.incr("mark_all_as_read", count) statsd.incr("mark_all_as_read", count)
all_push_message_ids = UserMessage.objects.filter(
user_profile=user_profile,
flags=UserMessage.flags.active_mobile_push_notification,
).values_list("message_id", flat=True)
do_clear_mobile_push_notifications_for_ids(user_profile, all_push_message_ids)
return count return count
def do_mark_stream_messages_as_read(user_profile: UserProfile, def do_mark_stream_messages_as_read(user_profile: UserProfile,
@ -3617,10 +3632,24 @@ def do_mark_stream_messages_as_read(user_profile: UserProfile,
all=False, all=False,
) )
send_event(event, [user_profile.id]) send_event(event, [user_profile.id])
do_clear_mobile_push_notifications_for_ids(user_profile, message_ids)
statsd.incr("mark_stream_as_read", count) statsd.incr("mark_stream_as_read", count)
return count return count
def do_clear_mobile_push_notifications_for_ids(user_profile: UserProfile,
message_ids: List[int]) -> None:
for user_message in UserMessage.objects.filter(
message_id__in=message_ids,
flags=UserMessage.flags.active_mobile_push_notification,
user_profile=user_profile):
event = {
"user_profile_id": user_profile.id,
"message_id": user_message.message_id,
"type": "remove",
}
queue_json_publish("missedmessage_mobile_notifications", event)
def do_update_message_flags(user_profile: UserProfile, def do_update_message_flags(user_profile: UserProfile,
client: Client, client: Client,
operation: str, operation: str,
@ -3665,6 +3694,9 @@ def do_update_message_flags(user_profile: UserProfile,
'all': False} 'all': False}
send_event(event, [user_profile.id]) send_event(event, [user_profile.id])
if flag == "read" and operation == "add":
do_clear_mobile_push_notifications_for_ids(user_profile, messages)
statsd.incr("flags.%s.%s" % (flag, operation), count) statsd.incr("flags.%s.%s" % (flag, operation), count)
return count return count

View File

@ -556,7 +556,17 @@ def handle_remove_push_notification(user_profile_id: int, message_id: int) -> No
""" """
user_profile = get_user_profile_by_id(user_profile_id) user_profile = get_user_profile_by_id(user_profile_id)
message = access_message(user_profile, message_id)[0] message, user_message = access_message(user_profile, message_id)
if not settings.SEND_REMOVE_PUSH_NOTIFICATIONS:
# It's a little annoying that we duplicate this flag-clearing
# code (also present below), but this block is scheduled to be
# removed in a few weeks, once the app has supported the
# feature for long enough.
user_message.flags.active_mobile_push_notification = False
user_message.save(update_fields=["flags"])
return
gcm_payload = get_common_payload(message) gcm_payload = get_common_payload(message)
gcm_payload.update({ gcm_payload.update({
'event': 'remove', 'event': 'remove',
@ -581,6 +591,9 @@ def handle_remove_push_notification(user_profile_id: int, message_id: int) -> No
if android_devices: if android_devices:
send_android_push_notification(android_devices, gcm_payload) send_android_push_notification(android_devices, gcm_payload)
user_message.flags.active_mobile_push_notification = False
user_message.save(update_fields=["flags"])
@statsd_increment("push_notifications") @statsd_increment("push_notifications")
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None:
""" """
@ -602,6 +615,12 @@ def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any
# the `zerver/tornado/event_queue.py` logic? # the `zerver/tornado/event_queue.py` logic?
if user_message.flags.read: if user_message.flags.read:
return return
# Otherwise, we mark the message as having an active mobile
# push notification, so that we can send revocation messages
# later.
user_message.flags.active_mobile_push_notification = True
user_message.save(update_fields=["flags"])
else: else:
# Users should only be getting push notifications into this # Users should only be getting push notifications into this
# queue for messages they haven't received if they're # queue for messages they haven't received if they're

View File

@ -1535,6 +1535,8 @@ class AbstractUserMessage(models.Model):
# Use this for Django ORM queries where we are getting lots # Use this for Django ORM queries where we are getting lots
# of rows. This custom SQL plays nice with our partial indexes. # of rows. This custom SQL plays nice with our partial indexes.
# Grep the code for example usage. # Grep the code for example usage.
#
# This optimization is only helpful for checking a flag being False.
return 'flags & 1 = 0' return 'flags & 1 = 0'
def flags_list(self) -> List[str]: def flags_list(self) -> List[str]:

View File

@ -536,6 +536,7 @@ class HandlePushNotificationTest(PushNotificationTest):
mock_send_android.assert_called_with(android_devices, mock_send_android.assert_called_with(android_devices,
{'gcm': True}) {'gcm': True})
@override_settings(SEND_REMOVE_PUSH_NOTIFICATIONS=True)
def test_send_remove_notifications_to_bouncer(self) -> None: def test_send_remove_notifications_to_bouncer(self) -> None:
user_profile = self.example_user('hamlet') user_profile = self.example_user('hamlet')
message = self.get_message(Recipient.PERSONAL, type_id=1) message = self.get_message(Recipient.PERSONAL, type_id=1)
@ -555,6 +556,7 @@ class HandlePushNotificationTest(PushNotificationTest):
'event': 'remove', 'event': 'remove',
'zulip_message_id': message.id}) 'zulip_message_id': message.id})
@override_settings(SEND_REMOVE_PUSH_NOTIFICATIONS=True)
def test_non_bouncer_push_remove(self) -> None: def test_non_bouncer_push_remove(self) -> None:
message = self.get_message(Recipient.PERSONAL, type_id=1) message = self.get_message(Recipient.PERSONAL, type_id=1)
UserMessage.objects.create( UserMessage.objects.create(

View File

@ -454,7 +454,7 @@ class LoginTest(ZulipTestCase):
with queries_captured() as queries: with queries_captured() as queries:
self.register(self.nonreg_email('test'), "test") self.register(self.nonreg_email('test'), "test")
# Ensure the number of queries we make is not O(streams) # Ensure the number of queries we make is not O(streams)
self.assert_length(queries, 77) self.assert_length(queries, 78)
user_profile = self.nonreg_user('test') user_profile = self.nonreg_user('test')
self.assertEqual(get_session_dict_user(self.client.session), user_profile.id) self.assertEqual(get_session_dict_user(self.client.session), user_profile.id)
self.assertFalse(user_profile.enable_stream_desktop_notifications) self.assertFalse(user_profile.enable_stream_desktop_notifications)

View File

@ -3,6 +3,7 @@
from typing import Any, Dict, List, Mapping from typing import Any, Dict, List, Mapping
from django.db import connection from django.db import connection
from django.test import override_settings
from zerver.models import ( from zerver.models import (
get_realm, get_realm,
@ -13,6 +14,7 @@ from zerver.models import (
Stream, Stream,
Subscription, Subscription,
UserMessage, UserMessage,
UserProfile,
) )
from zerver.lib.fix_unreads import ( from zerver.lib.fix_unreads import (
@ -471,3 +473,60 @@ class FixUnreadTests(ZulipTestCase):
assert_unread(um_muted_stream_id) assert_unread(um_muted_stream_id)
assert_unread(um_post_pointer_id) assert_unread(um_post_pointer_id)
assert_read(um_unsubscribed_id) assert_read(um_unsubscribed_id)
class PushNotificationMarkReadFlowsTest(ZulipTestCase):
def get_mobile_push_notification_ids(self, user_profile: UserProfile) -> List[int]:
return list(UserMessage.objects.filter(
user_profile=user_profile,
flags=UserMessage.flags.active_mobile_push_notification).order_by(
"message_id").values_list("message_id", flat=True))
@override_settings(SEND_REMOVE_PUSH_NOTIFICATIONS=True)
def test_track_active_mobile_push_notifications(self) -> None:
self.login(self.example_email("hamlet"))
user_profile = self.example_user('hamlet')
stream = self.subscribe(user_profile, "test_stream")
second_stream = self.subscribe(user_profile, "second_stream")
property_name = "push_notifications"
result = self.api_post(user_profile.email, "/api/v1/users/me/subscriptions/properties",
{"subscription_data": ujson.dumps([{"property": property_name,
"value": True,
"stream_id": stream.id}])})
result = self.api_post(user_profile.email, "/api/v1/users/me/subscriptions/properties",
{"subscription_data": ujson.dumps([{"property": property_name,
"value": True,
"stream_id": second_stream.id}])})
self.assert_json_success(result)
self.assertEqual(self.get_mobile_push_notification_ids(user_profile), [])
message_id = self.send_stream_message(self.example_email("cordelia"), "test_stream", "hello", "test_topic")
second_message_id = self.send_stream_message(self.example_email("cordelia"), "test_stream", "hello", "other_topic")
third_message_id = self.send_stream_message(self.example_email("cordelia"), "second_stream", "hello", "test_topic")
self.assertEqual(self.get_mobile_push_notification_ids(user_profile),
[message_id, second_message_id, third_message_id])
result = self.client_post("/json/mark_topic_as_read", {
"stream_id": str(stream.id),
"topic_name": "test_topic",
})
self.assert_json_success(result)
self.assertEqual(self.get_mobile_push_notification_ids(user_profile),
[second_message_id, third_message_id])
result = self.client_post("/json/mark_stream_as_read", {
"stream_id": str(stream.id),
"topic_name": "test_topic",
})
self.assertEqual(self.get_mobile_push_notification_ids(user_profile),
[third_message_id])
fourth_message_id = self.send_stream_message(self.example_email("cordelia"), "test_stream", "hello", "test_topic")
self.assertEqual(self.get_mobile_push_notification_ids(user_profile),
[third_message_id, fourth_message_id])
result = self.client_post("/json/mark_all_as_read", {})
self.assertEqual(self.get_mobile_push_notification_ids(user_profile),
[])

View File

@ -22,7 +22,7 @@ from zerver.lib.feedback import handle_feedback
from zerver.lib.queue import SimpleQueueClient, queue_json_publish, retry_event from zerver.lib.queue import SimpleQueueClient, queue_json_publish, retry_event
from zerver.lib.timestamp import timestamp_to_datetime from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.notifications import handle_missedmessage_emails from zerver.lib.notifications import handle_missedmessage_emails
from zerver.lib.push_notifications import handle_push_notification from zerver.lib.push_notifications import handle_push_notification, handle_remove_push_notification
from zerver.lib.actions import do_send_confirmation_email, \ from zerver.lib.actions import do_send_confirmation_email, \
do_update_user_activity, do_update_user_activity_interval, do_update_user_presence, \ do_update_user_activity, do_update_user_activity_interval, do_update_user_presence, \
internal_send_message, check_send_message, extract_recipients, \ internal_send_message, check_send_message, extract_recipients, \
@ -306,6 +306,8 @@ class MissedMessageSendingWorker(EmailSendingWorker): # nocoverage
@assign_queue('missedmessage_mobile_notifications') @assign_queue('missedmessage_mobile_notifications')
class PushNotificationsWorker(QueueProcessingWorker): # nocoverage class PushNotificationsWorker(QueueProcessingWorker): # nocoverage
def consume(self, data: Mapping[str, Any]) -> None: def consume(self, data: Mapping[str, Any]) -> None:
if data.get("type", "add") == "remove":
handle_remove_push_notification(data['user_profile_id'], data['message_id'])
handle_push_notification(data['user_profile_id'], data) handle_push_notification(data['user_profile_id'], data)
# We probably could stop running this queue worker at all if ENABLE_FEEDBACK is False # We probably could stop running this queue worker at all if ENABLE_FEEDBACK is False

View File

@ -220,6 +220,11 @@ DEFAULT_SETTINGS = {
'SEND_LOGIN_EMAILS': True, 'SEND_LOGIN_EMAILS': True,
'EMBEDDED_BOTS_ENABLED': False, 'EMBEDDED_BOTS_ENABLED': False,
# Temporary setting while we wait for app support for removing
# push notifications. Controls whether the Zulip server sends
# cancellation notices for previously sent push notifications.
'SEND_REMOVE_PUSH_NOTIFICATIONS': False,
# Two Factor Authentication is not yet implementation-complete # Two Factor Authentication is not yet implementation-complete
'TWO_FACTOR_AUTHENTICATION_ENABLED': False, 'TWO_FACTOR_AUTHENTICATION_ENABLED': False,