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..10e25fcefb 100644 --- a/lib/api/model/json.dart +++ b/lib/api/model/json.dart @@ -12,16 +12,54 @@ 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); + @override bool operator ==(Object other) { if (other is! JsonNullable) return false; @@ -32,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(); @@ -42,10 +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(); } 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/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( 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}; 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', +}; diff --git a/lib/model/push_device.dart b/lib/model/push_device.dart index 0b82b3c24a..8665dae22c 100644 --- a/lib/model/push_device.dart +++ b/lib/model/push_device.dart @@ -1,13 +1,23 @@ import 'dart:async'; +import 'dart:math'; +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(); } @@ -23,9 +33,78 @@ 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; + } + } + } + + /// 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 + // 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 +117,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 +163,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/model/store.dart b/lib/model/store.dart index e0b021a135..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), @@ -865,6 +866,11 @@ class PerAccountStore extends PerAccountStoreBase with } notifyListeners(); + case DeviceEvent(): + assert(debugLog("server event: device")); + pushDevices.handleDeviceEvent(event); + notifyListeners(); + case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); _realm.handleCustomProfileFieldsEvent(event); 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: 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 80c7cd8339..fd964aa6c7 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; @@ -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,