hardened API decoding against server fields the models did not expect
The Talk room list crashed for a new account because the server omits
lastMessage for rooms without a visible message and the model required it.
Beyond that single field, a review of the Talk models against the OpenAPI
spec of the installed version (23.0.8) found the same crash class in several
places: closed enums over open server values (inCall bitmask, rich-object
types, actor and message types), a deleted parent sent as {id, deleted},
and the autocomplete status arriving as an object.
- json_serializable `checked: true`: decode errors now name class and field
- lists (rooms, messages, participants, autocomplete) decode per entry;
a malformed entry is skipped and reported via ClientErrorReporter
- unknownEnumValue fallbacks for all response enums of Talk and breakers
- lastMessage nullable; inCall as bitmask; deleted parent placeholder;
autocomplete status object/string; fixed shareWithDisplayNameUnique key
- GetRoomParams no longer sends null query parameters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
This commit is contained in:
@@ -7,24 +7,33 @@ part of 'get_chat_params.dart';
|
||||
// **************************************************************************
|
||||
|
||||
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
|
||||
GetChatParams(
|
||||
lookIntoFuture: $enumDecode(
|
||||
_$GetChatParamsSwitchEnumMap,
|
||||
json['lookIntoFuture'],
|
||||
),
|
||||
limit: (json['limit'] as num?)?.toInt(),
|
||||
lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(),
|
||||
lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(),
|
||||
timeout: (json['timeout'] as num?)?.toInt(),
|
||||
setReadMarker: $enumDecodeNullable(
|
||||
_$GetChatParamsSwitchEnumMap,
|
||||
json['setReadMarker'],
|
||||
),
|
||||
includeLastKnown: $enumDecodeNullable(
|
||||
_$GetChatParamsSwitchEnumMap,
|
||||
json['includeLastKnown'],
|
||||
),
|
||||
);
|
||||
$checkedCreate('GetChatParams', json, ($checkedConvert) {
|
||||
final val = GetChatParams(
|
||||
lookIntoFuture: $checkedConvert(
|
||||
'lookIntoFuture',
|
||||
(v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
|
||||
),
|
||||
limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()),
|
||||
lastKnownMessageId: $checkedConvert(
|
||||
'lastKnownMessageId',
|
||||
(v) => (v as num?)?.toInt(),
|
||||
),
|
||||
lastCommonReadId: $checkedConvert(
|
||||
'lastCommonReadId',
|
||||
(v) => (v as num?)?.toInt(),
|
||||
),
|
||||
timeout: $checkedConvert('timeout', (v) => (v as num?)?.toInt()),
|
||||
setReadMarker: $checkedConvert(
|
||||
'setReadMarker',
|
||||
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
|
||||
),
|
||||
includeLastKnown: $checkedConvert(
|
||||
'includeLastKnown',
|
||||
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
|
||||
),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$GetChatParamsToJson(
|
||||
GetChatParams instance,
|
||||
|
||||
@@ -2,12 +2,14 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../api_response.dart';
|
||||
import '../../../json_lenient.dart';
|
||||
import '../room/get_room_response.dart';
|
||||
|
||||
part 'get_chat_response.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class GetChatResponse extends ApiResponse {
|
||||
@JsonKey(fromJson: _messagesFromJson)
|
||||
Set<GetChatResponseObject> data;
|
||||
|
||||
GetChatResponse(this.data);
|
||||
@@ -23,15 +25,20 @@ class GetChatResponse extends ApiResponse {
|
||||
}
|
||||
}
|
||||
|
||||
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
|
||||
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class GetChatResponseObject {
|
||||
int id;
|
||||
String token;
|
||||
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
|
||||
GetRoomResponseObjectMessageActorType actorType;
|
||||
String actorId;
|
||||
String actorDisplayName;
|
||||
int timestamp;
|
||||
String systemMessage;
|
||||
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
|
||||
GetRoomResponseObjectMessageType messageType;
|
||||
bool isReplyable;
|
||||
String referenceId;
|
||||
@@ -40,6 +47,11 @@ class GetChatResponseObject {
|
||||
List<String>? reactionsSelf;
|
||||
@JsonKey(fromJson: _fromJson)
|
||||
Map<String, RichObjectString>? messageParameters;
|
||||
|
||||
/// The quoted message. When the original is no longer available (expired,
|
||||
/// purged) the server only sends `{id, deleted: true}`; that becomes a
|
||||
/// deleted-comment placeholder so the quote still renders.
|
||||
@JsonKey(fromJson: _parentFromJson)
|
||||
GetChatResponseObject? parent;
|
||||
|
||||
/// Whether the server flagged this message to be rendered as Markdown
|
||||
@@ -73,11 +85,29 @@ class GetChatResponseObject {
|
||||
/// [timestamp] is in seconds.
|
||||
DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||
|
||||
static GetChatResponseObject getDateDummy(int timestamp) =>
|
||||
getTextDummy(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamp * 1000,
|
||||
).formatDateRelativeShort(),
|
||||
static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamp * 1000,
|
||||
).formatDateRelativeShort(),
|
||||
);
|
||||
|
||||
static GetChatResponseObject deletedPlaceholder(int id) =>
|
||||
GetChatResponseObject(
|
||||
id,
|
||||
'',
|
||||
GetRoomResponseObjectMessageActorType.user,
|
||||
'',
|
||||
'',
|
||||
0,
|
||||
'',
|
||||
GetRoomResponseObjectMessageType.deletedComment,
|
||||
false,
|
||||
'',
|
||||
'Nachricht wurde gelöscht',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
static GetChatResponseObject getTextDummy(String text) =>
|
||||
@@ -100,14 +130,24 @@ class GetChatResponseObject {
|
||||
);
|
||||
}
|
||||
|
||||
GetChatResponseObject? _parentFromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
if (json['deleted'] == true) {
|
||||
return GetChatResponseObject.deletedPlaceholder(
|
||||
(json['id'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
return GetChatResponseObject.fromJson(json);
|
||||
}
|
||||
|
||||
Map<String, RichObjectString>? _fromJson(dynamic json) {
|
||||
if (json is Map<String, dynamic>) {
|
||||
final data = <String, RichObjectString>{};
|
||||
for (final element in json.keys) {
|
||||
data.putIfAbsent(
|
||||
element,
|
||||
() => RichObjectString.fromJson(json[element] as Map<String, dynamic>),
|
||||
);
|
||||
for (final entry in json.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
data[entry.key] = RichObjectString.fromJson(value);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -117,6 +157,7 @@ Map<String, RichObjectString>? _fromJson(dynamic json) {
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class RichObjectString {
|
||||
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
|
||||
RichObjectStringObjectType type;
|
||||
String id;
|
||||
String name;
|
||||
@@ -148,4 +189,14 @@ enum RichObjectStringObjectType {
|
||||
geoLocation,
|
||||
@JsonValue('call')
|
||||
call,
|
||||
@JsonValue('user-group')
|
||||
userGroup,
|
||||
@JsonValue('circle')
|
||||
circle,
|
||||
@JsonValue('email')
|
||||
email,
|
||||
|
||||
/// Anything else (deck cards, contacts, calendar events, ...); the
|
||||
/// placeholder still resolves to the object's name.
|
||||
unknown,
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ part of 'get_chat_response.dart';
|
||||
// **************************************************************************
|
||||
|
||||
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
|
||||
GetChatResponse(
|
||||
(json['data'] as List<dynamic>)
|
||||
.map(
|
||||
(e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toSet(),
|
||||
)
|
||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, e as String),
|
||||
$checkedCreate('GetChatResponse', json, ($checkedConvert) {
|
||||
final val = GetChatResponse(
|
||||
$checkedConvert('data', (v) => _messagesFromJson(v)),
|
||||
);
|
||||
$checkedConvert(
|
||||
'headers',
|
||||
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, e as String),
|
||||
),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -26,31 +28,49 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
|
||||
|
||||
GetChatResponseObject _$GetChatResponseObjectFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => GetChatResponseObject(
|
||||
(json['id'] as num).toInt(),
|
||||
json['token'] as String,
|
||||
$enumDecode(
|
||||
_$GetRoomResponseObjectMessageActorTypeEnumMap,
|
||||
json['actorType'],
|
||||
),
|
||||
json['actorId'] as String,
|
||||
json['actorDisplayName'] as String,
|
||||
(json['timestamp'] as num).toInt(),
|
||||
json['systemMessage'] as String,
|
||||
$enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']),
|
||||
json['isReplyable'] as bool,
|
||||
json['referenceId'] as String,
|
||||
json['message'] as String,
|
||||
_fromJson(json['messageParameters']),
|
||||
(json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, (e as num).toInt()),
|
||||
),
|
||||
(json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
json['parent'] == null
|
||||
? null
|
||||
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
|
||||
json['markdown'] as bool? ?? false,
|
||||
);
|
||||
) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) {
|
||||
final val = GetChatResponseObject(
|
||||
$checkedConvert('id', (v) => (v as num).toInt()),
|
||||
$checkedConvert('token', (v) => v as String),
|
||||
$checkedConvert(
|
||||
'actorType',
|
||||
(v) => $enumDecode(
|
||||
_$GetRoomResponseObjectMessageActorTypeEnumMap,
|
||||
v,
|
||||
unknownValue: GetRoomResponseObjectMessageActorType.unknown,
|
||||
),
|
||||
),
|
||||
$checkedConvert('actorId', (v) => v as String),
|
||||
$checkedConvert('actorDisplayName', (v) => v as String),
|
||||
$checkedConvert('timestamp', (v) => (v as num).toInt()),
|
||||
$checkedConvert('systemMessage', (v) => v as String),
|
||||
$checkedConvert(
|
||||
'messageType',
|
||||
(v) => $enumDecode(
|
||||
_$GetRoomResponseObjectMessageTypeEnumMap,
|
||||
v,
|
||||
unknownValue: GetRoomResponseObjectMessageType.unknown,
|
||||
),
|
||||
),
|
||||
$checkedConvert('isReplyable', (v) => v as bool),
|
||||
$checkedConvert('referenceId', (v) => v as String),
|
||||
$checkedConvert('message', (v) => v as String),
|
||||
$checkedConvert('messageParameters', (v) => _fromJson(v)),
|
||||
$checkedConvert(
|
||||
'reactions',
|
||||
(v) => (v as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, (e as num).toInt()),
|
||||
),
|
||||
),
|
||||
$checkedConvert(
|
||||
'reactionsSelf',
|
||||
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
),
|
||||
$checkedConvert('parent', (v) => _parentFromJson(v)),
|
||||
$checkedConvert('markdown', (v) => v as bool? ?? false),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||
GetChatResponseObject instance,
|
||||
@@ -83,6 +103,12 @@ const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
|
||||
GetRoomResponseObjectMessageActorType.guest: 'guests',
|
||||
GetRoomResponseObjectMessageActorType.bot: 'bots',
|
||||
GetRoomResponseObjectMessageActorType.bridge: 'bridged',
|
||||
GetRoomResponseObjectMessageActorType.group: 'groups',
|
||||
GetRoomResponseObjectMessageActorType.email: 'emails',
|
||||
GetRoomResponseObjectMessageActorType.circle: 'circles',
|
||||
GetRoomResponseObjectMessageActorType.federatedUser: 'federated_users',
|
||||
GetRoomResponseObjectMessageActorType.phone: 'phones',
|
||||
GetRoomResponseObjectMessageActorType.unknown: 'unknown',
|
||||
};
|
||||
|
||||
const _$GetRoomResponseObjectMessageTypeEnumMap = {
|
||||
@@ -91,16 +117,27 @@ const _$GetRoomResponseObjectMessageTypeEnumMap = {
|
||||
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
|
||||
GetRoomResponseObjectMessageType.system: 'system',
|
||||
GetRoomResponseObjectMessageType.command: 'command',
|
||||
GetRoomResponseObjectMessageType.unknown: 'unknown',
|
||||
};
|
||||
|
||||
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
|
||||
RichObjectString(
|
||||
$enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']),
|
||||
json['id'] as String,
|
||||
json['name'] as String,
|
||||
json['path'] as String?,
|
||||
json['link'] as String?,
|
||||
);
|
||||
$checkedCreate('RichObjectString', json, ($checkedConvert) {
|
||||
final val = RichObjectString(
|
||||
$checkedConvert(
|
||||
'type',
|
||||
(v) => $enumDecode(
|
||||
_$RichObjectStringObjectTypeEnumMap,
|
||||
v,
|
||||
unknownValue: RichObjectStringObjectType.unknown,
|
||||
),
|
||||
),
|
||||
$checkedConvert('id', (v) => v as String),
|
||||
$checkedConvert('name', (v) => v as String),
|
||||
$checkedConvert('path', (v) => v as String?),
|
||||
$checkedConvert('link', (v) => v as String?),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -120,4 +157,8 @@ const _$RichObjectStringObjectTypeEnumMap = {
|
||||
RichObjectStringObjectType.talkPoll: 'talk-poll',
|
||||
RichObjectStringObjectType.geoLocation: 'geo-location',
|
||||
RichObjectStringObjectType.call: 'call',
|
||||
RichObjectStringObjectType.userGroup: 'user-group',
|
||||
RichObjectStringObjectType.circle: 'circle',
|
||||
RichObjectStringObjectType.email: 'email',
|
||||
RichObjectStringObjectType.unknown: 'unknown',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user