From 9396c93b454d2d012e5d697ed49ac58239ad5aea Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 18 Feb 2026 18:07:01 -0800 Subject: [PATCH 1/9] test: Bump eg.recentZulipFeatureLevel to current, FL 468 This keeps eg.account and eg.initialSnapshot using recent versions by default. This change has no effect on any existing logic, as seen by scanning the output of the following search: $ git grep 'zulipFeatureLevel [<>]' lib All the thresholds we currently condition on are from before 382. --- test/example_data.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index 80c7cd8339..5e9e63450a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -95,8 +95,8 @@ Uri get _realmUrl => realmUrl; final Uri realmIcon = Uri.parse('/user_avatars/2/realm/icon.png?version=3'); Uri get _realmIcon => realmIcon; -const String recentZulipVersion = '9.0'; -const int recentZulipFeatureLevel = 382; +const String recentZulipVersion = '11.0'; +const int recentZulipFeatureLevel = 468; const int futureZulipFeatureLevel = 9999; const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; From 94fb7d7286838171099cde3769b602ae1b8a0d00 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 27 Jan 2026 17:39:56 -0800 Subject: [PATCH 2/9] api: Add devices data from server This is only the second time we're using JsonNullable, and the first time we happen to be using it directly in`readValue`. It took me a bit to work out just how to use it here; I'll add more docs on JsonNullable in the next commit. --- lib/api/model/events.dart | 96 ++++++++++++++++++++++ lib/api/model/events.g.dart | 112 +++++++++++++++++++++++--- lib/api/model/initial_snapshot.dart | 3 + lib/api/model/initial_snapshot.g.dart | 7 ++ lib/api/model/json.dart | 10 +++ lib/api/model/model.dart | 22 +++++ lib/api/model/model.g.dart | 19 +++++ lib/model/store.dart | 4 + test/api/model/events_checks.dart | 8 ++ test/api/model/events_test.dart | 34 ++++++++ test/example_data.dart | 2 + 11 files changed, 307 insertions(+), 10 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index c03e29fae2..205b1c8077 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -29,6 +29,13 @@ sealed class Event { case 'update': return UserSettingsUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'device': + switch (json['op'] as String) { + case 'add': return DeviceAddEvent.fromJson(json); + case 'remove': return DeviceRemoveEvent.fromJson(json); + case 'update': return DeviceUpdateEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json); case 'user_group': switch (json['op'] as String) { @@ -210,6 +217,95 @@ class UserSettingsUpdateEvent extends Event { Map toJson() => _$UserSettingsUpdateEventToJson(this); } +/// A Zulip event of type `device`. +/// +/// See API docs starting at: +/// https://zulip.com/api/get-events#device-add +sealed class DeviceEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'device'; + + String get op; + + final int deviceId; + + DeviceEvent({required super.id, required this.deviceId}); +} + +/// A [DeviceEvent] with op `add`: https://zulip.com/api/get-events#device-add +@JsonSerializable(fieldRename: FieldRename.snake) +class DeviceAddEvent extends DeviceEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add'; + + DeviceAddEvent({required super.id, required super.deviceId}); + + factory DeviceAddEvent.fromJson(Map json) => + _$DeviceAddEventFromJson(json); + + @override + Map toJson() => _$DeviceAddEventToJson(this); +} + +/// A [DeviceEvent] with op `remove`: https://zulip.com/api/get-events#device-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class DeviceRemoveEvent extends DeviceEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove'; + + DeviceRemoveEvent({required super.id, required super.deviceId}); + + factory DeviceRemoveEvent.fromJson(Map json) => + _$DeviceRemoveEventFromJson(json); + + @override + Map toJson() => _$DeviceRemoveEventToJson(this); +} + +/// A [DeviceEvent] with op `update`: https://zulip.com/api/get-events#device-update +@JsonSerializable(fieldRename: FieldRename.snake) +@NullableIntJsonConverter() +@NullableStringJsonConverter() +class DeviceUpdateEvent extends DeviceEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'update'; + + @JsonKey(readValue: JsonNullable.readIntFromJson) + final JsonNullable? pushKeyId; + + @JsonKey(readValue: JsonNullable.readStringFromJson) + final JsonNullable? pushTokenId; + + @JsonKey(readValue: JsonNullable.readStringFromJson) + final JsonNullable? pendingPushTokenId; + + @JsonKey(readValue: JsonNullable.readIntFromJson) + final JsonNullable? pushTokenLastUpdatedTimestamp; + + @JsonKey(readValue: JsonNullable.readStringFromJson) + final JsonNullable? pushRegistrationErrorCode; + + DeviceUpdateEvent({ + required super.id, + required super.deviceId, + required this.pushKeyId, + required this.pushTokenId, + required this.pendingPushTokenId, + required this.pushTokenLastUpdatedTimestamp, + required this.pushRegistrationErrorCode, + }); + + factory DeviceUpdateEvent.fromJson(Map json) => + _$DeviceUpdateEventFromJson(json); + + @override + Map toJson() => _$DeviceUpdateEventToJson(this); +} + /// A Zulip event of type `custom_profile_fields`: https://zulip.com/api/get-events#custom_profile_fields @JsonSerializable(fieldRename: FieldRename.snake) class CustomProfileFieldsEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 63e5fb347a..a89c7ddd8f 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -71,6 +71,108 @@ const _$UserSettingNameEnumMap = { UserSettingName.presenceEnabled: 'presence_enabled', }; +DeviceAddEvent _$DeviceAddEventFromJson(Map json) => + DeviceAddEvent( + id: (json['id'] as num).toInt(), + deviceId: (json['device_id'] as num).toInt(), + ); + +Map _$DeviceAddEventToJson(DeviceAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'device_id': instance.deviceId, + 'op': instance.op, + }; + +DeviceRemoveEvent _$DeviceRemoveEventFromJson(Map json) => + DeviceRemoveEvent( + id: (json['id'] as num).toInt(), + deviceId: (json['device_id'] as num).toInt(), + ); + +Map _$DeviceRemoveEventToJson(DeviceRemoveEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'device_id': instance.deviceId, + 'op': instance.op, + }; + +DeviceUpdateEvent _$DeviceUpdateEventFromJson( + Map json, +) => DeviceUpdateEvent( + id: (json['id'] as num).toInt(), + deviceId: (json['device_id'] as num).toInt(), + pushKeyId: _$JsonConverterFromJson, JsonNullable>( + JsonNullable.readIntFromJson(json, 'push_key_id'), + const NullableIntJsonConverter().fromJson, + ), + pushTokenId: + _$JsonConverterFromJson, JsonNullable>( + JsonNullable.readStringFromJson(json, 'push_token_id'), + const NullableStringJsonConverter().fromJson, + ), + pendingPushTokenId: + _$JsonConverterFromJson, JsonNullable>( + JsonNullable.readStringFromJson(json, 'pending_push_token_id'), + const NullableStringJsonConverter().fromJson, + ), + pushTokenLastUpdatedTimestamp: + _$JsonConverterFromJson, JsonNullable>( + JsonNullable.readIntFromJson(json, 'push_token_last_updated_timestamp'), + const NullableIntJsonConverter().fromJson, + ), + pushRegistrationErrorCode: + _$JsonConverterFromJson, JsonNullable>( + JsonNullable.readStringFromJson(json, 'push_registration_error_code'), + const NullableStringJsonConverter().fromJson, + ), +); + +Map _$DeviceUpdateEventToJson( + DeviceUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'device_id': instance.deviceId, + 'op': instance.op, + 'push_key_id': _$JsonConverterToJson, JsonNullable>( + instance.pushKeyId, + const NullableIntJsonConverter().toJson, + ), + 'push_token_id': + _$JsonConverterToJson, JsonNullable>( + instance.pushTokenId, + const NullableStringJsonConverter().toJson, + ), + 'pending_push_token_id': + _$JsonConverterToJson, JsonNullable>( + instance.pendingPushTokenId, + const NullableStringJsonConverter().toJson, + ), + 'push_token_last_updated_timestamp': + _$JsonConverterToJson, JsonNullable>( + instance.pushTokenLastUpdatedTimestamp, + const NullableIntJsonConverter().toJson, + ), + 'push_registration_error_code': + _$JsonConverterToJson, JsonNullable>( + instance.pushRegistrationErrorCode, + const NullableStringJsonConverter().toJson, + ), +}; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); + CustomProfileFieldsEvent _$CustomProfileFieldsEventFromJson( Map json, ) => CustomProfileFieldsEvent( @@ -333,16 +435,6 @@ const _$UserRoleEnumMap = { UserRole.unknown: null, }; -Value? _$JsonConverterFromJson( - Object? json, - Value? Function(Json json) fromJson, -) => json == null ? null : fromJson(json as Json); - -Json? _$JsonConverterToJson( - Value? value, - Json? Function(Value value) toJson, -) => value == null ? null : toJson(value); - SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( Map json, ) => SavedSnippetsAddEvent( diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 203adf5a41..9c1dab4180 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -78,6 +78,8 @@ class InitialSnapshot { final List userTopics; + final Map? devices; // TODO(server-12) + final GroupSettingValue? realmCanDeleteAnyMessageGroup; // TODO(server-10) final GroupSettingValue? realmCanDeleteOwnMessageGroup; // TODO(server-10) @@ -188,6 +190,7 @@ class InitialSnapshot { required this.userStatuses, required this.userSettings, required this.userTopics, + required this.devices, required this.realmCanDeleteAnyMessageGroup, required this.realmCanDeleteOwnMessageGroup, required this.realmDeleteOwnMessagePolicy, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 094b923623..dd535eee21 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -89,6 +89,12 @@ InitialSnapshot _$InitialSnapshotFromJson( userTopics: (json['user_topics'] as List) .map((e) => UserTopicItem.fromJson(e as Map)) .toList(), + devices: (json['devices'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + ClientDevice.fromJson(e as Map), + ), + ), realmCanDeleteAnyMessageGroup: json['realm_can_delete_any_message_group'] == null ? null @@ -188,6 +194,7 @@ Map _$InitialSnapshotToJson( 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), 'user_settings': instance.userSettings, 'user_topics': instance.userTopics, + 'devices': instance.devices?.map((k, e) => MapEntry(k.toString(), e)), 'realm_can_delete_any_message_group': instance.realmCanDeleteAnyMessageGroup, 'realm_can_delete_own_message_group': instance.realmCanDeleteOwnMessageGroup, 'realm_delete_own_message_policy': instance.realmDeleteOwnMessagePolicy, diff --git a/lib/api/model/json.dart b/lib/api/model/json.dart index 092b047f83..42c356150c 100644 --- a/lib/api/model/json.dart +++ b/lib/api/model/json.dart @@ -22,6 +22,12 @@ class JsonNullable { return map.containsKey(key) ? JsonNullable(map[key] as T?) : null; } + static JsonNullable? readIntFromJson(Map map, String key) => + readFromJson(map, key); + + static JsonNullable? readStringFromJson(Map map, String key) => + readFromJson(map, key); + @override bool operator ==(Object other) { if (other is! JsonNullable) return false; @@ -46,6 +52,10 @@ class IdentityJsonConverter extends JsonConverter { // Just writing `@IdentityJsonConverter<…>` directly as the annotation // doesn't work, as json_serializable gets confused. Possibly related: // https://github.com/google/json_serializable.dart/issues/1398 +class NullableIntJsonConverter extends IdentityJsonConverter> { + const NullableIntJsonConverter(); +} + class NullableStringJsonConverter extends IdentityJsonConverter> { const NullableStringJsonConverter(); } diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 794103dd8a..9e5d8227d2 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -407,6 +407,28 @@ enum Emojiset { String toJson() => _$EmojisetEnumMap[this]!; } +@JsonSerializable(fieldRename: FieldRename.snake) +class ClientDevice { + int? pushKeyId; + String? pushTokenId; + String? pendingPushTokenId; + int? pushTokenLastUpdatedTimestamp; + String? pushRegistrationErrorCode; + + ClientDevice({ + required this.pushKeyId, + required this.pushTokenId, + required this.pendingPushTokenId, + required this.pushTokenLastUpdatedTimestamp, + required this.pushRegistrationErrorCode, + }); + + factory ClientDevice.fromJson(Map json) => + _$ClientDeviceFromJson(json); + + Map toJson() => _$ClientDeviceToJson(this); +} + /// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent]. @JsonSerializable(fieldRename: FieldRename.snake) class UserGroup { diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 489e3900ed..0c7d2dcd0e 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -112,6 +112,25 @@ Map _$RealmEmojiItemToJson(RealmEmojiItem instance) => 'author_id': instance.authorId, }; +ClientDevice _$ClientDeviceFromJson(Map json) => ClientDevice( + pushKeyId: (json['push_key_id'] as num?)?.toInt(), + pushTokenId: json['push_token_id'] as String?, + pendingPushTokenId: json['pending_push_token_id'] as String?, + pushTokenLastUpdatedTimestamp: + (json['push_token_last_updated_timestamp'] as num?)?.toInt(), + pushRegistrationErrorCode: json['push_registration_error_code'] as String?, +); + +Map _$ClientDeviceToJson( + ClientDevice instance, +) => { + 'push_key_id': instance.pushKeyId, + 'push_token_id': instance.pushTokenId, + 'pending_push_token_id': instance.pendingPushTokenId, + 'push_token_last_updated_timestamp': instance.pushTokenLastUpdatedTimestamp, + 'push_registration_error_code': instance.pushRegistrationErrorCode, +}; + UserGroup _$UserGroupFromJson(Map json) => UserGroup( id: (json['id'] as num).toInt(), members: (json['members'] as List) diff --git a/lib/model/store.dart b/lib/model/store.dart index e0b021a135..52627284b5 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -865,6 +865,10 @@ class PerAccountStore extends PerAccountStoreBase with } notifyListeners(); + case DeviceEvent(): + assert(debugLog("server event: device")); + // TODO(#1764): handle device events + case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); _realm.handleCustomProfileFieldsEvent(event); diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index d29a7acb65..2a12303434 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -15,6 +15,14 @@ extension AlertWordsEventChecks on Subject { Subject> get alertWords => has((e) => e.alertWords, 'alertWords'); } +extension DeviceUpdateEventChecks on Subject { + Subject?> get pushKeyId => has((e) => e.pushKeyId, 'pushKeyId'); + Subject?> get pushTokenId => has((e) => e.pushTokenId, 'pushTokenId'); + Subject?> get pendingPushTokenId => has((e) => e.pendingPushTokenId, 'pendingPushTokenId'); + Subject?> get pushTokenLastUpdatedTimestamp => has((e) => e.pushTokenLastUpdatedTimestamp, 'pushTokenLastUpdatedTimestamp'); + Subject?> get pushRegistrationErrorCode => has((e) => e.pushRegistrationErrorCode, 'pushRegistrationErrorCode'); +} + extension RealmUserUpdateEventChecks on Subject { Subject get userId => has((e) => e.userId, 'userId'); Subject get fullName => has((e) => e.fullName, 'fullName'); diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 7cdfa94eff..b5824f567a 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -30,6 +30,40 @@ void main() { ).isEmpty(); }); + group('device/update', () { + final baseJson = {'id': 1, 'type': 'device', 'op': 'update', 'device_id': 3 }; + + test('push_key_id absent', () { + check(Event.fromJson({ ...baseJson })) + .isA().pushKeyId.isNull(); + }); + + test('push_key_id null', () { + check(Event.fromJson({ ...baseJson, 'push_key_id': null })) + .isA().pushKeyId.equals(JsonNullable(null)); + }); + + test('push_key_id an int', () { + check(Event.fromJson({ ...baseJson, 'push_key_id': 123 })) + .isA().pushKeyId.equals(JsonNullable(123)); + }); + + test('pending_push_token_id absent', () { + check(Event.fromJson({ ...baseJson })) + .isA().pendingPushTokenId.isNull(); + }); + + test('pending_push_token_id null', () { + check(Event.fromJson({ ...baseJson, 'pending_push_token_id': null })) + .isA().pendingPushTokenId.equals(JsonNullable(null)); + }); + + test('pending_push_token_id a string', () { + check(Event.fromJson({ ...baseJson, 'pending_push_token_id': 'ab12' })) + .isA().pendingPushTokenId.equals(JsonNullable('ab12')); + }); + }); + group('realm_user/update', () { Map mkJson(Map data) => {'id': 1, 'type': 'realm_user', 'op': 'update', diff --git a/test/example_data.dart b/test/example_data.dart index 5e9e63450a..fd964aa6c7 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1353,6 +1353,7 @@ InitialSnapshot initialSnapshot({ Map? userStatuses, UserSettings? userSettings, List? userTopics, + Map? devices, GroupSettingValue? realmCanDeleteAnyMessageGroup, GroupSettingValue? realmCanDeleteOwnMessageGroup, RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy, @@ -1414,6 +1415,7 @@ InitialSnapshot initialSnapshot({ userStatuses: userStatuses ?? {}, userSettings: userSettings ?? _userSettings(), userTopics: userTopics ?? [], + devices: devices ?? {}, // no default; allow `null` to simulate servers without this realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, From 574952267aeda080d915d84af06700b2c2470e40 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 25 Feb 2026 15:01:35 -0800 Subject: [PATCH 3/9] api [nfc]: Expand docs on JsonNullable Based on the experience of using it for the second time, for DeviceUpdateEvent, in the preceding commit. --- lib/api/model/json.dart | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/api/model/json.dart b/lib/api/model/json.dart index 42c356150c..10e25fcefb 100644 --- a/lib/api/model/json.dart +++ b/lib/api/model/json.dart @@ -12,19 +12,51 @@ import 'package:json_annotation/json_annotation.dart'; /// the `name` property being absent in JSON; /// a value of `JsonNullable(null)` represents `'name': null` in JSON; /// and a value of `JsonNullable("foo")` represents `'name': 'foo'` in JSON. +/// +/// To use this class with [JsonSerializable]: +/// * On the field, apply [JsonKey.readValue] with a method from the +/// [readFromJson] family. +/// * On either the field or the class, apply an [IdentityJsonConverter] +/// subclass such as [NullableIntJsonConverter]. +/// * Both the read method and the converter need to have a concrete type, +/// not generic in T, because of limitations in `package:json_serializable`. +/// Go ahead and add them for more concrete types whenever needed. class JsonNullable { const JsonNullable(this.value); final T? value; + /// Reads a [JsonNullable] from a JSON map, as in [JsonKey.readValue]. + /// + /// The method actually passed to [JsonKey.readValue] needs a concrete type; + /// see the wrapper methods [readIntFromJson] and [readStringFromJson], + /// and add more freely as needed. + /// + /// This generic version is useful when writing a custom [JsonKey.readValue] + /// callback for other reasons, as well as for implementing those wrappers. + /// + /// Because the [JsonKey.readValue] return value is expected to still be + /// a JSON-like value that needs conversion, + /// the field (or the class) will also need to be annotated with + /// [IdentityJsonConverter] or a subclass. + /// The converter tells `package:json_serializable` how to convert + /// the [JsonNullable] from JSON: namely, by doing nothing. static JsonNullable? readFromJson( Map map, String key) { return map.containsKey(key) ? JsonNullable(map[key] as T?) : null; } + /// Reads a [JsonNullable] from a JSON map, as in [JsonKey.readValue]. + /// + /// The field or class will need to be annotated with [NullableIntJsonConverter]. + /// See [readFromJson]. static JsonNullable? readIntFromJson(Map map, String key) => readFromJson(map, key); + /// Reads a [JsonNullable] from a JSON map, as in [JsonKey.readValue]. + /// + /// The field or class will need to be annotated with [NullableStringJsonConverter]. + /// See [readFromJson]. static JsonNullable? readStringFromJson(Map map, String key) => readFromJson(map, key); @@ -38,6 +70,19 @@ class JsonNullable { int get hashCode => Object.hash('JsonNullable', value); } +/// "Converts" a value to and from JSON by using the value unmodified. +/// +/// This is useful when e.g. a [JsonKey.readValue] callback has already +/// effectively converted the value from JSON, +/// as [JsonNullable.readFromJson] does. +/// +/// The converter actually applied as an annotation needs a specific type. +/// Just writing `@IdentityJsonConverter<…>` directly as the annotation +/// doesn't work, as `package:json_serializable` gets confused; +/// instead, use a subclass like [NullableIntJsonConverter], +/// and add new such subclasses whenever needed. +// Possibly related to that issue with a generic converter: +// https://github.com/google/json_serializable.dart/issues/1398 class IdentityJsonConverter extends JsonConverter { const IdentityJsonConverter(); @@ -48,14 +93,18 @@ class IdentityJsonConverter extends JsonConverter { T toJson(T object) => object; } -// Make similar IdentityJsonConverter<…> subclasses as needed. -// Just writing `@IdentityJsonConverter<…>` directly as the annotation -// doesn't work, as json_serializable gets confused. Possibly related: -// https://github.com/google/json_serializable.dart/issues/1398 +/// "Converts" a [JsonNullable] to and from JSON by using it unmodified. +/// +/// This is useful with [JsonNullable.readIntFromJson]. +/// See there, and the base class [IdentityJsonConverter]. class NullableIntJsonConverter extends IdentityJsonConverter> { const NullableIntJsonConverter(); } +/// "Converts" a [JsonNullable] to and from JSON by using it unmodified. +/// +/// This is useful with [JsonNullable.readStringFromJson]. +/// See there, and the base class [IdentityJsonConverter]. class NullableStringJsonConverter extends IdentityJsonConverter> { const NullableStringJsonConverter(); } From 89dee358b2b9e625850ab2f0758a35e54ae2b6d1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 23 Sep 2025 18:10:20 -0700 Subject: [PATCH 4/9] push_device [nfc]: Move registerToken logic here from NotificationService This logic is more about Zulip's API than it is about the platform's support for notifications. As we implement the more complex token-registration model needed for E2EE notifications, we'll be adding further details from the Zulip API. So this is the better home for that logic. --- lib/model/push_device.dart | 34 ++++++++++++++++++++++++++++------ lib/notifications/receive.dart | 22 ---------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/model/push_device.dart b/lib/model/push_device.dart index 0b82b3c24a..ce9b7cd2a3 100644 --- a/lib/model/push_device.dart +++ b/lib/model/push_device.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + +import '../api/route/notifications.dart'; import '../notifications/receive.dart'; +import 'binding.dart'; import 'store.dart'; /// Manages telling the server this device's push token, @@ -26,6 +30,8 @@ class PushDeviceManager extends PerAccountStoreBase { /// Send this client's notification token to the server, now and if it changes. // TODO(#322) save acked token, to dedupe updating it on the server // TODO(#323) track the addFcmToken/etc request, warn if not succeeding + // TODO it would be nice to register the token before even registerQueue: + // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 void _registerTokenAndSubscribe() async { _debugMaybePause(); if (_debugRegisterTokenProceed != null) { @@ -38,12 +44,6 @@ class PushDeviceManager extends PerAccountStoreBase { _debugRegisterTokenCompleted?.complete(); } - Future _registerToken() async { - // TODO it would be nice to register the token before even registerQueue: - // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 - await NotificationService.instance.registerToken(connection); - } - Completer? _debugRegisterTokenProceed; Completer? _debugRegisterTokenCompleted; @@ -90,4 +90,26 @@ class PushDeviceManager extends PerAccountStoreBase { return true; }()); } + + Future _registerToken() async { + final token = NotificationService.instance.token.value; + if (token == null) return; + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + await addFcmToken(connection, token: token); + + case TargetPlatform.iOS: + final packageInfo = await ZulipBinding.instance.packageInfo; + await addApnsToken(connection, + token: token, + appid: packageInfo!.packageName); + + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + assert(false); + } + } } diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index d501a5d97c..39849ef7d5 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -147,28 +147,6 @@ class NotificationService { token.value = value; } - Future registerToken(ApiConnection connection) async { - final token = this.token.value; - if (token == null) return; - - switch (defaultTargetPlatform) { - case TargetPlatform.android: - await addFcmToken(connection, token: token); - - case TargetPlatform.iOS: - final packageInfo = await ZulipBinding.instance.packageInfo; - await addApnsToken(connection, - token: token, - appid: packageInfo!.packageName); - - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.fuchsia: - assert(false); - } - } - static Future unregisterToken(ApiConnection connection, {required String token}) async { switch (defaultTargetPlatform) { case TargetPlatform.android: From a00fd6c3062fd8d5756124152c26205bdc154b22 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 27 Jan 2026 17:49:04 -0800 Subject: [PATCH 5/9] push_device: Track devices data from server --- lib/model/push_device.dart | 58 ++++++++++++++++++++++++++++++++++++-- lib/model/store.dart | 6 ++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/lib/model/push_device.dart b/lib/model/push_device.dart index ce9b7cd2a3..4fc20d0a5c 100644 --- a/lib/model/push_device.dart +++ b/lib/model/push_device.dart @@ -1,17 +1,22 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import '../api/model/events.dart'; +import '../api/model/model.dart'; import '../api/route/notifications.dart'; import '../notifications/receive.dart'; import 'binding.dart'; import 'store.dart'; /// Manages telling the server this device's push token, -/// and tracking the server's responses on the status of push devices. -// TODO(#1764) do that tracking of responses +/// and tracking the server's responses on the status of devices and push tokens. class PushDeviceManager extends PerAccountStoreBase { - PushDeviceManager({required super.core}) { + PushDeviceManager({ + required super.core, + required Map devices, + }) : _devices = devices { _registerTokenAndSubscribe(); } @@ -27,6 +32,53 @@ class PushDeviceManager extends PerAccountStoreBase { _disposed = true; } + /// Like [InitialSnapshot.devices], but updated with events. + /// + /// For docs, search for "devices" + /// in . + /// + /// An absent map in [InitialSnapshot] (from an old server) is treated + /// as empty, since a server without this feature has none of these records. + // TODO(server-12) simplify doc re an absent map + late Map devices = UnmodifiableMapView(_devices); + final Map _devices; + + void handleDeviceEvent(DeviceEvent event) { + switch (event) { + case DeviceAddEvent(): + _devices[event.deviceId] = ClientDevice( + pushKeyId: null, + pushTokenId: null, + pendingPushTokenId: null, + pushTokenLastUpdatedTimestamp: null, + pushRegistrationErrorCode: null, + ); + + case DeviceRemoveEvent(): + _devices.remove(event.deviceId); + + case DeviceUpdateEvent(): + final device = _devices[event.deviceId]; + if (device == null) return; // TODO(log) + + if (event.pushKeyId case final v?) { + device.pushKeyId = v.value; + } + if (event.pushTokenId case final v?) { + device.pushTokenId = v.value; + } + if (event.pendingPushTokenId case final v?) { + device.pendingPushTokenId = v.value; + } + if (event.pushTokenLastUpdatedTimestamp case final v?) { + device.pushTokenLastUpdatedTimestamp = v.value; + } + if (event.pushRegistrationErrorCode case final v?) { + device.pushRegistrationErrorCode = v.value; + } + } + } + /// Send this client's notification token to the server, now and if it changes. // TODO(#322) save acked token, to dedupe updating it on the server // TODO(#323) track the addFcmToken/etc request, warn if not succeeding diff --git a/lib/model/store.dart b/lib/model/store.dart index 52627284b5..e0f9b588c2 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -628,7 +628,8 @@ class PerAccountStore extends PerAccountStoreBase with emoji: EmojiStoreImpl(core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, - pushDevices: PushDeviceManager(core: core), + pushDevices: PushDeviceManager(core: core, + devices: initialSnapshot.devices ?? {}), savedSnippets: SavedSnippetStoreImpl(core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), typingNotifier: TypingNotifier(realm: realm), @@ -867,7 +868,8 @@ class PerAccountStore extends PerAccountStoreBase with case DeviceEvent(): assert(debugLog("server event: device")); - // TODO(#1764): handle device events + pushDevices.handleDeviceEvent(event); + notifyListeners(); case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); From eb80f816d7405067f9e77a4205c0a0655a9e3af7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 27 Jan 2026 16:51:23 -0800 Subject: [PATCH 6/9] api: Add route registerClientDevice --- lib/api/route/account.dart | 19 +++++++++++++++++++ lib/api/route/account.g.dart | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/api/route/account.dart b/lib/api/route/account.dart index 1d29d3da1c..ba6bd23f44 100644 --- a/lib/api/route/account.dart +++ b/lib/api/route/account.dart @@ -32,3 +32,22 @@ class FetchApiKeyResult { Map toJson() => _$FetchApiKeyResultToJson(this); } + +/// https://zulip.com/api/register-client-device +Future registerClientDevice(ApiConnection connection) { + return connection.post('registerClientDevice', RegisterClientDeviceResult.fromJson, 'register_client_device', {}); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class RegisterClientDeviceResult { + final int deviceId; + + RegisterClientDeviceResult({ + required this.deviceId, + }); + + factory RegisterClientDeviceResult.fromJson(Map json) => + _$RegisterClientDeviceResultFromJson(json); + + Map toJson() => _$RegisterClientDeviceResultToJson(this); +} diff --git a/lib/api/route/account.g.dart b/lib/api/route/account.g.dart index b46fd098b9..031792b75f 100644 --- a/lib/api/route/account.g.dart +++ b/lib/api/route/account.g.dart @@ -21,3 +21,11 @@ Map _$FetchApiKeyResultToJson(FetchApiKeyResult instance) => 'email': instance.email, 'user_id': instance.userId, }; + +RegisterClientDeviceResult _$RegisterClientDeviceResultFromJson( + Map json, +) => RegisterClientDeviceResult(deviceId: (json['device_id'] as num).toInt()); + +Map _$RegisterClientDeviceResultToJson( + RegisterClientDeviceResult instance, +) => {'device_id': instance.deviceId}; From 9c7d50c202cc5526450aa16993d843938554274c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 22 Sep 2025 13:01:46 -0700 Subject: [PATCH 7/9] api: Add route registerPushDevice --- lib/api/route/notifications.dart | 83 ++++++++++++++++++++++++++++++ lib/api/route/notifications.g.dart | 21 ++++++++ 2 files changed, 104 insertions(+) create mode 100644 lib/api/route/notifications.g.dart diff --git a/lib/api/route/notifications.dart b/lib/api/route/notifications.dart index a8c7b23b36..5336f93e9f 100644 --- a/lib/api/route/notifications.dart +++ b/lib/api/route/notifications.dart @@ -1,5 +1,88 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../core.dart'; +part 'notifications.g.dart'; + +/// https://zulip.com/api/register-push-device +/// +/// The request's parameters are grouped here into [key] and [token] +/// to reflect the API's structure where each of those groups must be +/// either entirely absent or entirely present. +Future registerPushDevice(ApiConnection connection, { + required int deviceId, + RegisterPushDeviceKey? key, + RegisterPushDeviceToken? token, +}) { + assert(key != null || token != null); + assert(connection.zulipFeatureLevel! >= 468); // TODO(server-12) + return connection.post('registerPushDevice', (_) {}, 'mobile_push/register', { + 'device_id': deviceId, + if (key != null) ...{ + 'push_key_id': key.pushKeyId, + 'push_key': RawParameter(key.pushKey), + }, + if (token != null) ...{ + 'token_kind': RawParameter(token.tokenKind.toJson()), + 'token_id': RawParameter(token.tokenId), + 'bouncer_public_key': RawParameter(token.bouncerPublicKey), + 'encrypted_push_registration': RawParameter(token.encryptedPushRegistration), + }, + }); +} + +/// The parameters to [registerPushDevice] for setting a push key. +class RegisterPushDeviceKey { + final int pushKeyId; + final String pushKey; + + RegisterPushDeviceKey({required this.pushKeyId, required this.pushKey}); +} + +/// The parameters to [registerPushDevice] for setting a push token. +/// +/// For constructing [encryptedPushRegistration], see [PushRegistration]. +class RegisterPushDeviceToken { + final PushTokenKind tokenKind; + final String tokenId; + final String bouncerPublicKey; + final String encryptedPushRegistration; + + RegisterPushDeviceToken({ + required this.tokenKind, + required this.tokenId, + required this.bouncerPublicKey, + required this.encryptedPushRegistration, + }); +} + +/// As in [RegisterPushDeviceToken.tokenKind]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum PushTokenKind { + fcm, + apns; + + String toJson() => _$PushTokenKindEnumMap[this]!; +} + +/// The plaintext for [RegisterPushDeviceToken.encryptedPushRegistration]. +/// +/// See https://zulip.com/api/register-push-device#parameter-encrypted_push_registration +@JsonSerializable(fieldRename: FieldRename.snake, createFactory: false) +class PushRegistration { + final PushTokenKind tokenKind; + final String token; + final int timestamp; + + PushRegistration({ + required this.tokenKind, + required this.token, + required this.timestamp, + }); + + Map toJson() => _$PushRegistrationToJson(this); +} + /// https://zulip.com/api/add-fcm-token Future addFcmToken(ApiConnection connection, { required String token, diff --git a/lib/api/route/notifications.g.dart b/lib/api/route/notifications.g.dart new file mode 100644 index 0000000000..2b053172b2 --- /dev/null +++ b/lib/api/route/notifications.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'notifications.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$PushRegistrationToJson(PushRegistration instance) => + { + 'token_kind': instance.tokenKind, + 'token': instance.token, + 'timestamp': instance.timestamp, + }; + +const _$PushTokenKindEnumMap = { + PushTokenKind.fcm: 'fcm', + PushTokenKind.apns: 'apns', +}; From 0774d2cb6f0217ede6dc5670758a6e3c97cd27c7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 17 Feb 2026 21:55:03 -0800 Subject: [PATCH 8/9] notif: Add methods to generate push keys --- lib/model/push_device.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/model/push_device.dart b/lib/model/push_device.dart index 4fc20d0a5c..8665dae22c 100644 --- a/lib/model/push_device.dart +++ b/lib/model/push_device.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -79,6 +80,26 @@ class PushDeviceManager extends PerAccountStoreBase { } } + /// Generate a suitable value to pass as `pushKeyId` to [registerPushDevice]. + static int generatePushKeyId() { + final rand = Random.secure(); + return rand.nextInt(1 << 32); + } + + /// Generate a suitable value to pass as `pushKey` to [registerPushDevice]. + static Uint8List generatePushKey() { + final rand = Random.secure(); + return Uint8List.fromList([ + pushKeyTagSecretbox, + ...Iterable.generate(32, (_) => rand.nextInt(1 << 8)), + ]); + } + + /// The tag byte for a libsodium secretbox-based `pushKey` value. + /// + /// See API doc: https://zulip.com/api/register-push-device#parameter-push_key + static const pushKeyTagSecretbox = 0x31; + /// Send this client's notification token to the server, now and if it changes. // TODO(#322) save acked token, to dedupe updating it on the server // TODO(#323) track the addFcmToken/etc request, warn if not succeeding From 2d99c8268b2c5dd7644b2535244f26a05f3d190c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 13 Jan 2026 16:45:36 -0800 Subject: [PATCH 9/9] api: Add EncryptedFcmMessage --- lib/api/notifications.dart | 31 ++++++++++++++++++++++++++++++- lib/api/notifications.g.dart | 13 +++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart index 1dae8bbe93..beb6d42e55 100644 --- a/lib/api/notifications.dart +++ b/lib/api/notifications.dart @@ -1,11 +1,40 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:json_annotation/json_annotation.dart'; import 'model/model.dart'; part 'notifications.g.dart'; -/// Parsed version of an FCM message, of any type. +/// An FCM message whose contents are encrypted end-to-end from the Zulip server. +/// +/// Once decrypted, the contents will become an [FcmMessage]. +/// See there also for background on FCM and FCM messages. +/// +/// API docs: +/// https://zulip.com/api/mobile-notifications#data-sent-to-fcm +@JsonSerializable(fieldRename: FieldRename.snake) +class EncryptedFcmMessage { + @_IntConverter() + final int pushKeyId; + + @JsonKey(fromJson: base64Decode, toJson: base64Encode) + final Uint8List encryptedData; + + EncryptedFcmMessage({required this.pushKeyId, required this.encryptedData}); + + factory EncryptedFcmMessage.fromJson(Map json) => + _$EncryptedFcmMessageFromJson(json); + + Map toJson() => _$EncryptedFcmMessageToJson(this); +} + +/// Parsed version of an FCM message, of any plaintext type. +/// +/// This represents the data either decrypted from an [EncryptedFcmMessage], +/// or (TODO(server-12)) delivered in plaintext directly as an FCM payload. /// /// For partial API docs, see: /// https://zulip.com/api/mobile-notifications diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart index 43e08dc4e8..2e50cfc571 100644 --- a/lib/api/notifications.g.dart +++ b/lib/api/notifications.g.dart @@ -8,6 +8,19 @@ part of 'notifications.dart'; // JsonSerializableGenerator // ************************************************************************** +EncryptedFcmMessage _$EncryptedFcmMessageFromJson(Map json) => + EncryptedFcmMessage( + pushKeyId: const _IntConverter().fromJson(json['push_key_id'] as String), + encryptedData: base64Decode(json['encrypted_data'] as String), + ); + +Map _$EncryptedFcmMessageToJson( + EncryptedFcmMessage instance, +) => { + 'push_key_id': const _IntConverter().toJson(instance.pushKeyId), + 'encrypted_data': base64Encode(instance.encryptedData), +}; + MessageFcmMessage _$MessageFcmMessageFromJson(Map json) => MessageFcmMessage( realmUrl: Uri.parse(