Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

notif: Parse notification messages, on Android #333

Merged
merged 5 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
api/notif: Parse notification messages the server sends over FCM
Much of this is based on the Kotlin code that does this same job
in the zulip-mobile RN app:
  android/app/src/main/java/com/zulipmobile/notifications/FcmMessage.kt
  android/app/src/test/java/com/zulipmobile/notifications/FcmMessageTest.kt

Those Kotlin tests benefit from a feature of their test framework
where when an expectation like `expect.that(foo).isEqualTo(bar)`
(analogous to our `check(foo).equals(bar)`) is unsatisfied, it
records the failure without immediately ending the test function.
As a result, when something goes wrong we get very fine-grained
results, much like we would with package:checks if all the test
functions were translated as `group` and the individual expectations
as `test`, rather than as `check` -- but without the overhead that
the latter approach requires in the source code, particularly in
making up names for the individual test cases.

In this version, we go ahead and translate to `group` and `test` for
a couple of the test functions where their lack would be most keenly
felt, resorting to a hack to avoid cluttering the code with lots of
individual names.  We let the rest be just `test` and `check`.
  • Loading branch information
gnprice authored and chrisbobbe committed Oct 25, 2023
commit 40f2560fd975a5bccd87daef88a2db3a49c2676d
274 changes: 274 additions & 0 deletions lib/api/notifications.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@

import 'package:json_annotation/json_annotation.dart';

part 'notifications.g.dart';

/// Parsed version of an FCM message, of any type.
///
/// Firebase Cloud Messaging (FCM) is the service run by Google that we use
/// for delivering notifications to Android devices. An FCM message may
/// be to tell us we should show a notification, or something else like
/// to remove one (because the user read the underlying Zulip message).
///
/// The word "message" can be confusing in this context,
/// and in our notification code we usually stick to more specific phrases:
///
/// * An "FCM message" is one of the blobs we receive over FCM; what FCM docs
/// call a "message", and is also known as a "data notification".
///
/// One of these might correspond to zero, one, or more actual notifications
/// we show in the UI.
///
/// * A "Zulip message" is the thing that in other Zulip contexts we call
/// simply a "message": a [Message], the central item in the Zulip app model.
sealed class FcmMessage {
FcmMessage();

factory FcmMessage.fromJson(Map<String, dynamic> json) {
switch (json['event']) {
case 'message': return MessageFcmMessage.fromJson(json);
case 'remove': return RemoveFcmMessage.fromJson(json);
default: return UnexpectedFcmMessage.fromJson(json);
}
}

Map<String, dynamic> toJson();
}

/// An [FcmMessage] of a type (a value of `event`) we didn't know about.
class UnexpectedFcmMessage extends FcmMessage {
final Map<String, dynamic> json;

UnexpectedFcmMessage.fromJson(this.json);

@override
Map<String, dynamic> toJson() => json;
}

/// Base class for [FcmMessage]s that identify what Zulip account they're for.
///
/// This includes all known types of FCM messages from Zulip
/// (all [FcmMessage] subclasses other than [UnexpectedFcmMessage]),
/// and it seems likely that it always will.
sealed class FcmMessageWithIdentity extends FcmMessage {
/// The server's `EXTERNAL_HOST` setting. This is a hostname,
/// or a colon-separated hostname-plus-port.
///
/// For documentation, see zulip-server:zproject/prod_settings_template.py .
final String server;

/// The realm's ID within the server.
final int realmId;

/// The realm's own URL.
///
/// This is a real, absolute URL which is the base for all URLs a client uses
/// with this realm. It corresponds to [GetServerSettingsResult.realmUri].
final Uri realmUri;

/// This user's ID within the server.
///
/// Useful mainly in the case where the user has multiple accounts in the
/// same realm.
final int userId;

FcmMessageWithIdentity({
required this.server,
required this.realmId,
required this.realmUri,
required this.userId,
});
}

/// Parsed version of an FCM message of type `message`.
///
/// This corresponds to a Zulip message for which the user wants to
/// see a notification.
///
/// The word "message" can be confusing in this context.
/// See [FcmMessage] for discussion.
@JsonSerializable(fieldRename: FieldRename.snake)
@_IntConverter()
@_IntListConverter()
class MessageFcmMessage extends FcmMessageWithIdentity {
@JsonKey(includeToJson: true, name: 'event')
String get type => 'message';

final int senderId;
final String senderEmail;
final Uri senderAvatarUrl;
final String senderFullName;

@JsonKey(includeToJson: false, readValue: _readWhole)
final FcmMessageRecipient recipient;

final int zulipMessageId;
final int time; // in Unix seconds UTC, like [Message.timestamp]

/// The content of the Zulip message, rendered as plain text.
///
/// This is based on the HTML content, but reduced to plain text specifically
/// for use in notifications. For details, see `get_mobile_push_content` in
/// zulip/zulip:zerver/lib/push_notifications.py .
final String content;

static Object? _readWhole(Map json, String key) => json;

MessageFcmMessage({
required super.server,
required super.realmId,
required super.realmUri,
required super.userId,
required this.senderId,
required this.senderEmail,
required this.senderAvatarUrl,
required this.senderFullName,
required this.recipient,
required this.zulipMessageId,
required this.content,
required this.time,
});

factory MessageFcmMessage.fromJson(Map<String, dynamic> json) {
assert(json['event'] == 'message');
return _$MessageFcmMessageFromJson(json);
}

@override
Map<String, dynamic> toJson() {
final result = _$MessageFcmMessageToJson(this);
final recipient = this.recipient;
switch (recipient) {
case FcmMessageDmRecipient(allRecipientIds: [_] || [_, _]):
break;
case FcmMessageDmRecipient(:var allRecipientIds):
result['pm_users'] = const _IntListConverter().toJson(allRecipientIds);
case FcmMessageStreamRecipient():
result['stream_id'] = const _IntConverter().toJson(recipient.streamId);
if (recipient.streamName != null) result['stream'] = recipient.streamName;
result['topic'] = recipient.topic;
}
return result;
}
}

/// Data identifying where a Zulip message was sent, as part of an [FcmMessage].
sealed class FcmMessageRecipient {
FcmMessageRecipient();

factory FcmMessageRecipient.fromJson(Map<String, dynamic> json) {
// There's also a `recipient_type` field, but we don't really need it.
// The presence or absence of `stream_id` is just as informative.
return json.containsKey('stream_id')
? FcmMessageStreamRecipient.fromJson(json)
: FcmMessageDmRecipient.fromJson(json);
}
}

/// An [FcmMessageRecipient] for a Zulip message to a stream.
@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false)
@_IntConverter()
class FcmMessageStreamRecipient extends FcmMessageRecipient {
// Sending the stream ID in notifications is new in Zulip Server 5.
// But handling the lack of it would add complication, and we don't strictly
// need to -- we intend (#268) to cut pre-server-5 support before beta release.
// TODO(server-5): cut comment
final int streamId;

// Current servers (as of 2023) always send the stream name. But
// future servers might not, once clients get the name from local data.
// So might as well be ready.
@JsonKey(name: 'stream')
final String? streamName;

final String topic;

FcmMessageStreamRecipient({required this.streamId, required this.streamName, required this.topic});

factory FcmMessageStreamRecipient.fromJson(Map<String, dynamic> json) =>
_$FcmMessageStreamRecipientFromJson(json);
}

/// An [FcmMessageRecipient] for a Zulip message that was a DM.
class FcmMessageDmRecipient extends FcmMessageRecipient {
final List<int> allRecipientIds;

FcmMessageDmRecipient({required this.allRecipientIds});

factory FcmMessageDmRecipient.fromJson(Map<String, dynamic> json) {
return FcmMessageDmRecipient(allRecipientIds: switch (json) {
// Group DM conversations ("huddles") are represented with `pm_users`,
// which lists all the user IDs in the conversation.
// TODO check they're sorted.
{'pm_users': var pmUsers} => const _IntListConverter().fromJson(pmUsers),

// 1:1 DM conversations have no `pm_users`. Knowing that it's a
// 1:1 DM, `sender_id` is enough to identify the conversation.
{'sender_id': var senderId, 'user_id': var userId} =>
_pairSet(_parseInt(senderId), _parseInt(userId)),

_ => throw Exception("bad recipient"),
});
}

/// The set {id1, id2}, represented as a sorted list.
// (In set theory this is called the "pair" of id1 and id2: https://en.wikipedia.org/wiki/Axiom_of_pairing .)
static List<int> _pairSet(int id1, int id2) {
if (id1 == id2) return [id1];
if (id1 < id2) return [id1, id2];
return [id2, id1];
}
}

@JsonSerializable(fieldRename: FieldRename.snake)
@_IntConverter()
@_IntListConverter()
class RemoveFcmMessage extends FcmMessageWithIdentity {
@JsonKey(includeToJson: true, name: 'event')
String get type => 'remove';

// Servers have sent zulipMessageIds, obsoleting the singular zulipMessageId
// and just sending the first ID there redundantly, since 2019.
// See zulip-mobile@4acd07376.

final List<int> zulipMessageIds;
// final String? zulipMessageId; // obsolete; ignore

RemoveFcmMessage({
required super.server,
required super.realmId,
required super.realmUri,
required super.userId,
required this.zulipMessageIds,
});

factory RemoveFcmMessage.fromJson(Map<String, dynamic> json) {
assert(json['event'] == 'remove');
return _$RemoveFcmMessageFromJson(json);
}

@override
Map<String, dynamic> toJson() => _$RemoveFcmMessageToJson(this);
}

class _IntListConverter extends JsonConverter<List<int>, String> {
const _IntListConverter();

@override
List<int> fromJson(String json) => json.split(',').map(_parseInt).toList();

@override
String toJson(List<int> value) => value.join(',');
}

class _IntConverter extends JsonConverter<int, String> {
const _IntConverter();

@override
int fromJson(String json) => _parseInt(json);

@override
String toJson(int value) => value.toString();
}

int _parseInt(String string) => int.parse(string, radix: 10);
73 changes: 73 additions & 0 deletions lib/api/notifications.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading