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:
2026-09-29 16:35:41 +02:00
co-authored by Claude Fable 5.1
parent a79b376bb3
commit 279b973e89
92 changed files with 2150 additions and 963 deletions
@@ -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',
};