diff --git a/zerver/lib/push_notifications.py b/zerver/lib/push_notifications.py index 326fba0bda..b178dda0d3 100644 --- a/zerver/lib/push_notifications.py +++ b/zerver/lib/push_notifications.py @@ -13,7 +13,7 @@ import lxml.html import orjson from django.conf import settings from django.db import IntegrityError, transaction -from django.db.models import F +from django.db.models import F, Q from django.utils.timezone import now as timezone_now from django.utils.translation import gettext as _ from django.utils.translation import override as override_language @@ -58,6 +58,49 @@ def hex_to_b64(data: str) -> str: return base64.b64encode(bytes.fromhex(data)).decode() +class UserPushIndentityCompat: + """Compatibility class for supporting the transition from remote servers + sending their UserProfile ids to the bouncer to sending UserProfile uuids instead. + + Until we can drop support for receiving user_id, we need this + class, because a user's identity in the push notification context + may be represented either by an id or uuid. + """ + + def __init__(self, user_id: Optional[int] = None, user_uuid: Optional[str] = None) -> None: + assert user_id is not None or user_uuid is not None + self.user_id = user_id + self.user_uuid = user_uuid + + def filter_q(self) -> Q: + """ + This aims to support correctly querying for RemotePushDeviceToken. + If only one of (user_id, user_uuid) is provided, the situation is trivial, + If both are provided, we want to query for tokens matching EITHER the + uuid or the id - because the user may have devices with old registrations, + so user_id-based, as well as new registration with uuid. Notifications + naturally should be sent to both. + """ + if self.user_id is not None and self.user_uuid is None: + return Q(user_id=self.user_id) + elif self.user_uuid is not None and self.user_id is None: + return Q(user_uuid=self.user_uuid) + else: + assert self.user_id is not None and self.user_uuid is not None + return Q(user_uuid=self.user_uuid) | Q(user_id=self.user_id) + + def __str__(self) -> str: + if self.user_uuid is not None: + return f"uuid:{self.user_uuid}" + + return f"id:{self.user_id}" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, UserPushIndentityCompat): + return self.user_id == other.user_id and self.user_uuid == other.user_uuid + return False + + # # Sending to APNs, for iOS # @@ -134,7 +177,7 @@ APNS_MAX_RETRIES = 3 @statsd_increment("apple_push_notification") def send_apple_push_notification( - user_id: int, + user_identity: UserPushIndentityCompat, devices: Sequence[DeviceToken], payload_data: Dict[str, Any], remote: Optional["RemoteZulipServer"] = None, @@ -164,14 +207,16 @@ def send_apple_push_notification( if remote: logger.info( - "APNs: Sending notification for remote user %s:%d to %d devices", + "APNs: Sending notification for remote user %s:%s to %d devices", remote.uuid, - user_id, + user_identity, len(devices), ) else: logger.info( - "APNs: Sending notification for local user %d to %d devices", user_id, len(devices) + "APNs: Sending notification for local user %s to %d devices", + user_identity, + len(devices), ) payload_data = modernize_apns_payload(payload_data).copy() message = {**payload_data.pop("custom", {}), "aps": payload_data} @@ -187,15 +232,17 @@ def send_apple_push_notification( ) except aioapns.exceptions.ConnectionError as e: logger.warning( - "APNs: ConnectionError sending for user %d to device %s: %s", - user_id, + "APNs: ConnectionError sending for user %s to device %s: %s", + user_identity, device.token, e.__class__.__name__, ) continue if result.is_successful: - logger.info("APNs: Success sending for user %d to device %s", user_id, device.token) + logger.info( + "APNs: Success sending for user %s to device %s", user_identity, device.token + ) elif result.description in ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"]: logger.info( "APNs: Removing invalid/expired token %s (%s)", device.token, result.description @@ -205,8 +252,8 @@ def send_apple_push_notification( DeviceTokenClass.objects.filter(token=device.token, kind=DeviceTokenClass.APNS).delete() else: logger.warning( - "APNs: Failed to send for user %d to device %s: %s", - user_id, + "APNs: Failed to send for user %s to device %s: %s", + user_identity, device.token, result.description, ) @@ -256,7 +303,9 @@ def send_android_push_notification_to_user( user_profile: UserProfile, data: Dict[str, Any], options: Dict[str, Any] ) -> None: devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) - send_android_push_notification(user_profile.id, devices, data, options) + send_android_push_notification( + UserPushIndentityCompat(user_id=user_profile.id), devices, data, options + ) def parse_gcm_options(options: Dict[str, Any], data: Dict[str, Any]) -> str: @@ -306,7 +355,7 @@ def parse_gcm_options(options: Dict[str, Any], data: Dict[str, Any]) -> str: @statsd_increment("android_push_notification") def send_android_push_notification( - user_id: int, + user_identity: UserPushIndentityCompat, devices: Sequence[DeviceToken], data: Dict[str, Any], options: Dict[str, Any], @@ -334,14 +383,14 @@ def send_android_push_notification( if remote: logger.info( - "GCM: Sending notification for remote user %s:%d to %d devices", + "GCM: Sending notification for remote user %s:%s to %d devices", remote.uuid, - user_id, + user_identity, len(devices), ) else: logger.info( - "GCM: Sending notification for local user %d to %d devices", user_id, len(devices) + "GCM: Sending notification for local user %s to %d devices", user_identity, len(devices) ) reg_ids = [device.token for device in devices] priority = parse_gcm_options(options, data) @@ -947,6 +996,7 @@ def handle_remove_push_notification(user_profile_id: int, message_ids: List[int] if uses_notification_bouncer(): send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload, gcm_options) else: + user_identity = UserPushIndentityCompat(user_id=user_profile_id) android_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM) ) @@ -954,11 +1004,9 @@ def handle_remove_push_notification(user_profile_id: int, message_ids: List[int] PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS) ) if android_devices: - send_android_push_notification( - user_profile_id, android_devices, gcm_payload, gcm_options - ) + send_android_push_notification(user_identity, android_devices, gcm_payload, gcm_options) if apple_devices: - send_apple_push_notification(user_profile_id, apple_devices, apns_payload) + send_apple_push_notification(user_identity, apple_devices, apns_payload) # We intentionally use the non-truncated message_ids here. We are # assuming in this very rare case that the user has manually @@ -1077,5 +1125,6 @@ def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any len(android_devices), len(apple_devices), ) - send_apple_push_notification(user_profile.id, apple_devices, apns_payload) - send_android_push_notification(user_profile.id, android_devices, gcm_payload, gcm_options) + user_identity = UserPushIndentityCompat(user_id=user_profile.id) + send_apple_push_notification(user_identity, apple_devices, apns_payload) + send_android_push_notification(user_identity, android_devices, gcm_payload, gcm_options) diff --git a/zerver/tests/test_push_notifications.py b/zerver/tests/test_push_notifications.py index 13cf263aab..ef4ee4dd37 100644 --- a/zerver/tests/test_push_notifications.py +++ b/zerver/tests/test_push_notifications.py @@ -13,7 +13,7 @@ import orjson import responses from django.conf import settings from django.db import transaction -from django.db.models import F +from django.db.models import F, Q from django.http.response import ResponseHeaders from django.test import override_settings from django.utils.crypto import get_random_string @@ -34,6 +34,7 @@ from zerver.lib.exceptions import JsonableError from zerver.lib.push_notifications import ( APNsContext, DeviceToken, + UserPushIndentityCompat, b64_to_hex, get_apns_badge_count, get_apns_badge_count_future, @@ -192,7 +193,13 @@ class PushBouncerNotificationTest(BouncerTestCase): result = self.uuid_post( self.server_uuid, endpoint, {"token": token, "token_kind": token_kind} ) - self.assert_json_error(result, "Missing 'user_id' argument") + self.assert_json_error(result, "Missing user_id or user_uuid") + result = self.uuid_post( + self.server_uuid, + endpoint, + {"user_id": user_id, "user_uuid": "xxx", "token": token, "token_kind": token_kind}, + ) + self.assert_json_error(result, "Specify only one of user_id or user_uuid") result = self.uuid_post( self.server_uuid, endpoint, {"user_id": user_id, "token": token, "token_kind": 17} ) @@ -367,12 +374,14 @@ class PushBouncerNotificationTest(BouncerTestCase): logger.output, [ "INFO:zilencer.views:" - f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{hamlet.id}: " + f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{hamlet.id}: " "2 via FCM devices, 1 via APNs devices" ], ) + + user_identity = UserPushIndentityCompat(user_id=hamlet.id) apple_push.assert_called_once_with( - hamlet.id, + user_identity, [apple_token], { "badge": 0, @@ -386,7 +395,7 @@ class PushBouncerNotificationTest(BouncerTestCase): remote=server, ) android_push.assert_called_once_with( - hamlet.id, + user_identity, list(reversed(android_tokens)), {"event": "remove", "zulip_message_ids": ",".join(str(i) for i in range(50, 250))}, {}, @@ -962,7 +971,9 @@ class HandlePushNotificationTest(PushNotificationTest): (b64_to_hex(device.token), device.ios_app_id, device.token) for device in RemotePushDeviceToken.objects.filter(kind=PushDeviceToken.GCM) ] - mock_gcm.json_request.return_value = {"success": {gcm_devices[0][2]: message.id}} + mock_gcm.json_request.return_value = { + "success": {device[2]: message.id for device in gcm_devices} + } result = mock.Mock() result.is_successful = True apns_context.apns.send_notification.return_value = asyncio.Future( @@ -974,14 +985,14 @@ class HandlePushNotificationTest(PushNotificationTest): views_logger.output, [ "INFO:zilencer.views:" - f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{self.user_profile.id}: " + f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{self.user_profile.id}: " f"{len(gcm_devices)} via FCM devices, {len(apns_devices)} via APNs devices" ], ) for _, _, token in apns_devices: self.assertIn( "INFO:zerver.lib.push_notifications:" - f"APNs: Success sending for user {self.user_profile.id} to device {token}", + f"APNs: Success sending for user id:{self.user_profile.id} to device {token}", pn_logger.output, ) for _, _, token in gcm_devices: @@ -1035,7 +1046,7 @@ class HandlePushNotificationTest(PushNotificationTest): views_logger.output, [ "INFO:zilencer.views:" - f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{self.user_profile.id}: " + f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{self.user_profile.id}: " f"{len(gcm_devices)} via FCM devices, {len(apns_devices)} via APNs devices" ], ) @@ -1249,10 +1260,9 @@ class HandlePushNotificationTest(PushNotificationTest): ) as mock_push_notifications: handle_push_notification(self.user_profile.id, missed_message) - mock_send_apple.assert_called_with(self.user_profile.id, apple_devices, {"apns": True}) - mock_send_android.assert_called_with( - self.user_profile.id, android_devices, {"gcm": True}, {} - ) + user_identity = UserPushIndentityCompat(user_id=self.user_profile.id) + mock_send_apple.assert_called_with(user_identity, apple_devices, {"apns": True}) + mock_send_android.assert_called_with(user_identity, android_devices, {"gcm": True}, {}) mock_push_notifications.assert_called_once() def test_send_remove_notifications_to_bouncer(self) -> None: @@ -1324,8 +1334,9 @@ class HandlePushNotificationTest(PushNotificationTest): ) as mock_send_apple: handle_remove_push_notification(self.user_profile.id, [message.id]) mock_push_notifications.assert_called_once() + user_identity = UserPushIndentityCompat(user_id=self.user_profile.id) mock_send_android.assert_called_with( - self.user_profile.id, + user_identity, android_devices, { "server": "testserver", @@ -1339,7 +1350,7 @@ class HandlePushNotificationTest(PushNotificationTest): {"priority": "normal"}, ) mock_send_apple.assert_called_with( - self.user_profile.id, + user_identity, apple_devices, { "badge": 0, @@ -1451,10 +1462,9 @@ class HandlePushNotificationTest(PushNotificationTest): ) as mock_push_notifications: handle_push_notification(self.user_profile.id, missed_message) mock_logger.assert_not_called() - mock_send_apple.assert_called_with(self.user_profile.id, apple_devices, {"apns": True}) - mock_send_android.assert_called_with( - self.user_profile.id, android_devices, {"gcm": True}, {} - ) + user_identity = UserPushIndentityCompat(user_id=self.user_profile.id) + mock_send_apple.assert_called_with(user_identity, apple_devices, {"apns": True}) + mock_send_android.assert_called_with(user_identity, android_devices, {"gcm": True}, {}) mock_push_notifications.assert_called_once() @mock.patch("zerver.lib.push_notifications.logger.info") @@ -1492,7 +1502,9 @@ class TestAPNs(PushNotificationTest): payload_data: Dict[str, Any] = {}, ) -> None: send_apple_push_notification( - self.user_profile.id, devices if devices is not None else self.devices(), payload_data + UserPushIndentityCompat(user_id=self.user_profile.id), + devices if devices is not None else self.devices(), + payload_data, ) def test_get_apns_context(self) -> None: @@ -1559,7 +1571,7 @@ class TestAPNs(PushNotificationTest): self.send() for device in self.devices(): self.assertIn( - f"INFO:zerver.lib.push_notifications:APNs: Success sending for user {self.user_profile.id} to device {device.token}", + f"INFO:zerver.lib.push_notifications:APNs: Success sending for user id:{self.user_profile.id} to device {device.token}", logger.output, ) @@ -1576,7 +1588,7 @@ class TestAPNs(PushNotificationTest): ) self.send(devices=self.devices()[0:1]) self.assertIn( - f"WARNING:zerver.lib.push_notifications:APNs: ConnectionError sending for user {self.user_profile.id} to device {self.devices()[0].token}: ConnectionError", + f"WARNING:zerver.lib.push_notifications:APNs: ConnectionError sending for user id:{self.user_profile.id} to device {self.devices()[0].token}: ConnectionError", logger.output, ) @@ -1594,7 +1606,7 @@ class TestAPNs(PushNotificationTest): apns_context.apns.send_notification.return_value.set_result(result) self.send(devices=self.devices()[0:1]) self.assertIn( - f"WARNING:zerver.lib.push_notifications:APNs: Failed to send for user {self.user_profile.id} to device {self.devices()[0].token}: InternalServerError", + f"WARNING:zerver.lib.push_notifications:APNs: Failed to send for user id:{self.user_profile.id} to device {self.devices()[0].token}: InternalServerError", logger.output, ) @@ -2340,7 +2352,7 @@ class GCMSendTest(PushNotificationTest): with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger: send_android_push_notification_to_user(self.user_profile, data, {}) self.assert_length(logger.output, 3) - log_msg1 = f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices" + log_msg1 = f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices" log_msg2 = f"INFO:zerver.lib.push_notifications:GCM: Sent {1111} as {0}" log_msg3 = f"INFO:zerver.lib.push_notifications:GCM: Sent {2222} as {1}" self.assertEqual([log_msg1, log_msg2, log_msg3], logger.output) @@ -2400,7 +2412,7 @@ class GCMSendTest(PushNotificationTest): with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger: send_android_push_notification_to_user(self.user_profile, data, {}) self.assertEqual( - f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices", + f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices", logger.output[0], ) self.assertEqual( @@ -2427,7 +2439,7 @@ class GCMSendTest(PushNotificationTest): with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger: send_android_push_notification_to_user(self.user_profile, data, {}) self.assertEqual( - f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices", + f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices", logger.output[0], ) self.assertEqual( @@ -2668,3 +2680,24 @@ class PushBouncerSignupTest(ZulipTestCase): self.assert_json_error( result, f"Zulip server auth failure: key does not match role {zulip_org_id}" ) + + +class TestUserPushIndentityCompat(ZulipTestCase): + def test_filter_q(self) -> None: + user_identity_id = UserPushIndentityCompat(user_id=1) + user_identity_uuid = UserPushIndentityCompat(user_uuid="aaaa") + user_identity_both = UserPushIndentityCompat(user_id=1, user_uuid="aaaa") + + self.assertEqual(user_identity_id.filter_q(), Q(user_id=1)) + self.assertEqual(user_identity_uuid.filter_q(), Q(user_uuid="aaaa")) + self.assertEqual(user_identity_both.filter_q(), Q(user_uuid="aaaa") | Q(user_id=1)) + + def test_eq(self) -> None: + user_identity_a = UserPushIndentityCompat(user_id=1) + user_identity_b = UserPushIndentityCompat(user_id=1) + user_identity_c = UserPushIndentityCompat(user_id=2) + self.assertEqual(user_identity_a, user_identity_b) + self.assertNotEqual(user_identity_a, user_identity_c) + + # An integer can't be equal to an instance of the class. + self.assertNotEqual(user_identity_a, 1) diff --git a/zilencer/migrations/0024_remotepushdevicetoken_user_uuid.py b/zilencer/migrations/0024_remotepushdevicetoken_user_uuid.py new file mode 100644 index 0000000000..8917de2e91 --- /dev/null +++ b/zilencer/migrations/0024_remotepushdevicetoken_user_uuid.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.9 on 2021-12-27 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("zilencer", "0023_remotezulipserver_deactivated"), + ] + + operations = [ + migrations.AlterField( + model_name="remotepushdevicetoken", + name="user_id", + field=models.BigIntegerField(db_index=True, null=True), + ), + migrations.AddField( + model_name="remotepushdevicetoken", + name="user_uuid", + field=models.UUIDField(null=True), + ), + migrations.AlterUniqueTogether( + name="remotepushdevicetoken", + unique_together={ + ("server", "user_uuid", "kind", "token"), + ("server", "user_id", "kind", "token"), + }, + ), + ] diff --git a/zilencer/models.py b/zilencer/models.py index 2c13919d51..9f879c76af 100644 --- a/zilencer/models.py +++ b/zilencer/models.py @@ -63,10 +63,18 @@ class RemotePushDeviceToken(AbstractPushDeviceToken): server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) # The user id on the remote server for this device - user_id: int = models.BigIntegerField(db_index=True) + user_id: int = models.BigIntegerField(db_index=True, null=True) + user_uuid: UUID = models.UUIDField(null=True) class Meta: - unique_together = ("server", "user_id", "kind", "token") + unique_together = [ + # These indexes rely on the property that in Postgres, + # NULL != NULL in the context of unique indexes, so multiple + # rows with the same values in these columns can exist + # if one of them is NULL. + ("server", "user_id", "kind", "token"), + ("server", "user_uuid", "kind", "token"), + ] def __str__(self) -> str: return f"" diff --git a/zilencer/views.py b/zilencer/views.py index c48b538fcb..7a32baf04e 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -17,6 +17,7 @@ from corporate.lib.stripe import do_deactivate_remote_server from zerver.decorator import InvalidZulipServerKeyError, require_post from zerver.lib.exceptions import JsonableError from zerver.lib.push_notifications import ( + UserPushIndentityCompat, send_android_push_notification, send_apple_push_notification, ) @@ -153,17 +154,28 @@ def register_remote_server( def register_remote_push_device( request: HttpRequest, entity: Union[UserProfile, RemoteZulipServer], - user_id: int = REQ(json_validator=check_int), + user_id: Optional[int] = REQ(json_validator=check_int, default=None), + user_uuid: Optional[str] = REQ(default=None), token: str = REQ(), token_kind: int = REQ(json_validator=check_int), ios_app_id: Optional[str] = None, ) -> HttpResponse: server = validate_bouncer_token_request(entity, token, token_kind) + if user_id is None and user_uuid is None: + raise JsonableError(_("Missing user_id or user_uuid")) + if user_id is not None and user_uuid is not None: + # We don't want "hybrid" registrations with both. + # Our RemotePushDeviceToken should be either in the new uuid format + # or the legacy id one. + raise JsonableError(_("Specify only one of user_id or user_uuid")) + try: with transaction.atomic(): RemotePushDeviceToken.objects.create( + # Exactly one of these two user identity fields will be None. user_id=user_id, + user_uuid=user_uuid, server=server, kind=token_kind, token=token, @@ -183,12 +195,15 @@ def unregister_remote_push_device( entity: Union[UserProfile, RemoteZulipServer], token: str = REQ(), token_kind: int = REQ(json_validator=check_int), - user_id: int = REQ(json_validator=check_int), + user_id: Optional[int] = REQ(json_validator=check_int, default=None), + user_uuid: Optional[str] = REQ(default=None), ios_app_id: Optional[str] = None, ) -> HttpResponse: server = validate_bouncer_token_request(entity, token, token_kind) + user_identity = UserPushIndentityCompat(user_id=user_id, user_uuid=user_uuid) + deleted = RemotePushDeviceToken.objects.filter( - token=token, kind=token_kind, user_id=user_id, server=server + user_identity.filter_q(), token=token, kind=token_kind, server=server ).delete() if deleted[0] == 0: raise JsonableError(err_("Token does not exist")) @@ -200,10 +215,13 @@ def unregister_remote_push_device( def unregister_all_remote_push_devices( request: HttpRequest, entity: Union[UserProfile, RemoteZulipServer], - user_id: int = REQ(json_validator=check_int), + user_id: Optional[int] = REQ(json_validator=check_int, default=None), + user_uuid: Optional[str] = REQ(default=None), ) -> HttpResponse: server = validate_entity(entity) - RemotePushDeviceToken.objects.filter(user_id=user_id, server=server).delete() + user_identity = UserPushIndentityCompat(user_id=user_id, user_uuid=user_uuid) + + RemotePushDeviceToken.objects.filter(user_identity.filter_q(), server=server).delete() return json_success(request) @@ -215,14 +233,15 @@ def remote_server_notify_push( ) -> HttpResponse: server = validate_entity(entity) - user_id = payload["user_id"] + user_identity = UserPushIndentityCompat(payload.get("user_id"), payload.get("user_uuid")) + gcm_payload = payload["gcm_payload"] apns_payload = payload["apns_payload"] gcm_options = payload.get("gcm_options", {}) android_devices = list( RemotePushDeviceToken.objects.filter( - user_id=user_id, + user_identity.filter_q(), kind=RemotePushDeviceToken.GCM, server=server, ) @@ -230,7 +249,7 @@ def remote_server_notify_push( apple_devices = list( RemotePushDeviceToken.objects.filter( - user_id=user_id, + user_identity.filter_q(), kind=RemotePushDeviceToken.APNS, server=server, ) @@ -239,7 +258,7 @@ def remote_server_notify_push( logger.info( "Sending mobile push notifications for remote user %s:%s: %s via FCM devices, %s via APNs devices", server.uuid, - user_id, + user_identity, len(android_devices), len(apple_devices), ) @@ -265,14 +284,14 @@ def remote_server_notify_push( gcm_payload = truncate_payload(gcm_payload) send_android_push_notification( - user_id, android_devices, gcm_payload, gcm_options, remote=server + user_identity, android_devices, gcm_payload, gcm_options, remote=server ) if isinstance(apns_payload.get("custom"), dict) and isinstance( apns_payload["custom"].get("zulip"), dict ): apns_payload["custom"]["zulip"] = truncate_payload(apns_payload["custom"]["zulip"]) - send_apple_push_notification(user_id, apple_devices, apns_payload, remote=server) + send_apple_push_notification(user_identity, apple_devices, apns_payload, remote=server) return json_success( request,