Skip to content

Commit 1f2aa0e

Browse files
notif ios: Add parser for iOS APNs payload
Introduces NotificationOpenPayload.parseIosApnsPayload which can parse the payload that Apple push notification service delivers to the app for displaying a notification. It retrieves the navigation data for the specific message notification.
1 parent 57eea75 commit 1f2aa0e

File tree

2 files changed

+161
-1
lines changed

2 files changed

+161
-1
lines changed

lib/notifications/open.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,72 @@ class NotificationOpenPayload {
9898
required this.narrow,
9999
});
100100

101+
/// Parses the iOS APNs payload and retrieves the information
102+
/// required for navigation.
103+
factory NotificationOpenPayload.parseIosApnsPayload(Map<Object?, Object?> payload) {
104+
if (payload case {
105+
'zulip': {
106+
'user_id': final int userId,
107+
'sender_id': final int senderId,
108+
} && final zulipData,
109+
}) {
110+
final eventType = zulipData['event'];
111+
if (eventType != null && eventType != 'message') {
112+
// On Android, we also receive "remove" notification messages, tagged
113+
// with an `event` field with value 'remove'. As of Zulip Server 10,
114+
// however, these are not yet sent to iOS devices, and we don't have a
115+
// way to handle them even if they were.
116+
//
117+
// The messages we currently do receive, and can handle, are analogous
118+
// to Android notification messages of event type 'message'. On the
119+
// assumption that some future version of the Zulip server will send
120+
// explicit event types in APNs messages, accept messages with that
121+
// `event` value, but no other.
122+
throw const FormatException();
123+
}
124+
125+
final realmUrl = switch (zulipData) {
126+
{'realm_url': final String value} => value,
127+
{'realm_uri': final String value} => value,
128+
_ => throw const FormatException(),
129+
};
130+
131+
final narrow = switch (zulipData) {
132+
{
133+
'recipient_type': 'stream',
134+
// TODO(server-5) remove this comment.
135+
// We require 'stream_id' here but that is new from Server 5.0,
136+
// resulting in failure on pre-5.0 servers.
137+
'stream_id': final int streamId,
138+
'topic': final String topic,
139+
} =>
140+
TopicNarrow(streamId, TopicName(topic)),
141+
142+
{'recipient_type': 'private', 'pm_users': final String pmUsers} =>
143+
DmNarrow(
144+
allRecipientIds: pmUsers
145+
.split(',')
146+
.map((e) => int.parse(e, radix: 10))
147+
.toList(growable: false)
148+
..sort(),
149+
selfUserId: userId),
150+
151+
{'recipient_type': 'private'} =>
152+
DmNarrow.withUser(senderId, selfUserId: userId),
153+
154+
_ => throw const FormatException(),
155+
};
156+
157+
return NotificationOpenPayload(
158+
realmUrl: Uri.parse(realmUrl),
159+
userId: userId,
160+
narrow: narrow);
161+
} else {
162+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
163+
throw const FormatException();
164+
}
165+
}
166+
101167
/// Parses the internal Android notification url, that was created using
102168
/// [buildAndroidNotificationUrl], and retrieves the information required
103169
/// for navigation.

test/notifications/open_test.dart

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,50 @@ import '../widgets/message_list_checks.dart';
2525
import '../widgets/page_checks.dart';
2626
import 'display_test.dart';
2727

28+
Map<String, Object?> messageApnsPayload(
29+
Message zulipMessage, {
30+
String? streamName,
31+
Account? account,
32+
}) {
33+
account ??= eg.selfAccount;
34+
return {
35+
"aps": {
36+
"alert": {
37+
"title": "test",
38+
"subtitle": "test",
39+
"body": zulipMessage.content,
40+
},
41+
"sound": "default",
42+
"badge": 0,
43+
},
44+
"zulip": {
45+
"server": "zulip.example.cloud",
46+
"realm_id": 4,
47+
"realm_uri": account.realmUrl.toString(),
48+
"realm_url": account.realmUrl.toString(),
49+
"realm_name": "Test",
50+
"user_id": account.userId,
51+
"sender_id": zulipMessage.senderId,
52+
"sender_email": zulipMessage.senderEmail,
53+
"time": zulipMessage.timestamp,
54+
"message_ids": [zulipMessage.id],
55+
...(switch (zulipMessage) {
56+
StreamMessage(:var streamId, :var topic) => {
57+
"recipient_type": "stream",
58+
"stream_id": streamId,
59+
if (streamName != null) "stream": streamName,
60+
"topic": topic,
61+
},
62+
DmMessage(allRecipientIds: [_, _, _, ...]) => {
63+
"recipient_type": "private",
64+
"pm_users": zulipMessage.allRecipientIds.join(","),
65+
},
66+
DmMessage() => {"recipient_type": "private"},
67+
}),
68+
},
69+
};
70+
}
71+
2872
void main() {
2973
TestZulipBinding.ensureInitialized();
3074
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
@@ -262,7 +306,7 @@ void main() {
262306
});
263307

264308
group('NotificationOpenPayload', () {
265-
test('smoke round-trip', () {
309+
test('android: smoke round-trip', () {
266310
// DM narrow
267311
var payload = NotificationOpenPayload(
268312
realmUrl: Uri.parse('http://chat.example'),
@@ -288,6 +332,56 @@ void main() {
288332
..narrow.equals(payload.narrow);
289333
});
290334

335+
group('parseIosApnsPayload', () {
336+
test('smoke one-one DM', () {
337+
final userA = eg.user(userId: 1001);
338+
final userB = eg.user(userId: 1002);
339+
final account = eg.account(
340+
realmUrl: Uri.parse('http://chat.example'),
341+
user: userA);
342+
final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]),
343+
account: account);
344+
check(NotificationOpenPayload.parseIosApnsPayload(payload))
345+
..realmUrl.equals(Uri.parse('http://chat.example'))
346+
..userId.equals(1001)
347+
..narrow.which((it) => it.isA<DmNarrow>()
348+
..otherRecipientIds.deepEquals([1002]));
349+
});
350+
351+
test('smoke group DM', () {
352+
final userA = eg.user(userId: 1001);
353+
final userB = eg.user(userId: 1002);
354+
final userC = eg.user(userId: 1003);
355+
final account = eg.account(
356+
realmUrl: Uri.parse('http://chat.example'),
357+
user: userA);
358+
final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]),
359+
account: account);
360+
check(NotificationOpenPayload.parseIosApnsPayload(payload))
361+
..realmUrl.equals(Uri.parse('http://chat.example'))
362+
..userId.equals(1001)
363+
..narrow.which((it) => it.isA<DmNarrow>()
364+
..otherRecipientIds.deepEquals([1002, 1003]));
365+
});
366+
367+
test('smoke topic message', () {
368+
final userA = eg.user(userId: 1001);
369+
final account = eg.account(
370+
realmUrl: Uri.parse('http://chat.example'),
371+
user: userA);
372+
final payload = messageApnsPayload(eg.streamMessage(
373+
stream: eg.stream(streamId: 1),
374+
topic: 'topic A'),
375+
account: account);
376+
check(NotificationOpenPayload.parseIosApnsPayload(payload))
377+
..realmUrl.equals(Uri.parse('http://chat.example'))
378+
..userId.equals(1001)
379+
..narrow.which((it) => it.isA<TopicNarrow>()
380+
..streamId.equals(1)
381+
..topic.equals(TopicName('topic A')));
382+
});
383+
});
384+
291385
group('buildAndroidNotificationUrl', () {
292386
test('smoke DM', () {
293387
final url = NotificationOpenPayload(

0 commit comments

Comments
 (0)