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
@@ -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,
}