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,13 +7,17 @@ part of 'breaker_state.dart';
// **************************************************************************
_BreakerState _$BreakerStateFromJson(Map<String, dynamic> json) =>
_BreakerState(
response: json['response'] == null
? null
: GetBreakersResponse.fromJson(
json['response'] as Map<String, dynamic>,
),
);
$checkedCreate('_BreakerState', json, ($checkedConvert) {
final val = _BreakerState(
response: $checkedConvert(
'response',
(v) => v == null
? null
: GetBreakersResponse.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$BreakerStateToJson(_BreakerState instance) =>
<String, dynamic>{'response': instance.response};
@@ -7,14 +7,29 @@ part of 'capabilities_state.dart';
// **************************************************************************
_CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
_CapabilitiesState(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?,
loaded: json['loaded'] as bool? ?? false,
);
$checkedCreate('_CapabilitiesState', json, ($checkedConvert) {
final val = _CapabilitiesState(
viewForeignTimetables: $checkedConvert(
'viewForeignTimetables',
(v) => v as bool? ?? false,
),
pushNotifications: $checkedConvert(
'pushNotifications',
(v) => v as bool? ?? false,
),
timetablePastDays: $checkedConvert(
'timetablePastDays',
(v) => (v as num?)?.toInt(),
),
timetableFutureDays: $checkedConvert(
'timetableFutureDays',
(v) => (v as num?)?.toInt(),
),
userType: $checkedConvert('userType', (v) => v as String?),
loaded: $checkedConvert('loaded', (v) => v as bool? ?? false),
);
return val;
});
Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
<String, dynamic>{
@@ -411,6 +411,7 @@ class ChatBloc
case GetRoomResponseObjectMessageType.deletedComment:
case GetRoomResponseObjectMessageType.system:
case GetRoomResponseObjectMessageType.command:
case GetRoomResponseObjectMessageType.unknown:
break;
}
}
@@ -6,14 +6,30 @@ part of 'chat_state.dart';
// JsonSerializableGenerator
// **************************************************************************
_ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
currentToken: json['currentToken'] as String? ?? '',
chatResponse: json['chatResponse'] == null
? null
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
_ChatState _$ChatStateFromJson(Map<String, dynamic> json) => $checkedCreate(
'_ChatState',
json,
($checkedConvert) {
final val = _ChatState(
currentToken: $checkedConvert('currentToken', (v) => v as String? ?? ''),
chatResponse: $checkedConvert(
'chatResponse',
(v) => v == null
? null
: GetChatResponse.fromJson(v as Map<String, dynamic>),
),
referenceMessageId: $checkedConvert(
'referenceMessageId',
(v) => (v as num?)?.toInt(),
),
hasMoreOld: $checkedConvert('hasMoreOld', (v) => v as bool? ?? true),
isLoadingOlder: $checkedConvert(
'isLoadingOlder',
(v) => v as bool? ?? false,
),
);
return val;
},
);
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
@@ -187,16 +187,18 @@ class ChatListBloc
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
final wasRead = r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final lastId = r.lastMessage?.id ?? 0;
final hasNewer = lastId >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
if (message.id > lastId) r.lastMessage = message;
if (message.timestamp > r.lastActivity) {
r.lastActivity = message.timestamp;
}
return true;
});
}
@@ -7,11 +7,17 @@ part of 'chat_list_state.dart';
// **************************************************************************
_ChatListState _$ChatListStateFromJson(Map<String, dynamic> json) =>
_ChatListState(
rooms: json['rooms'] == null
? null
: GetRoomResponse.fromJson(json['rooms'] as Map<String, dynamic>),
);
$checkedCreate('_ChatListState', json, ($checkedConvert) {
final val = _ChatListState(
rooms: $checkedConvert(
'rooms',
(v) => v == null
? null
: GetRoomResponse.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$ChatListStateToJson(_ChatListState instance) =>
<String, dynamic>{'rooms': instance.rooms};
@@ -6,16 +6,24 @@ part of 'files_state.dart';
// JsonSerializableGenerator
// **************************************************************************
_FilesState _$FilesStateFromJson(Map<String, dynamic> json) => _FilesState(
currentPath:
(json['currentPath'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const <String>[],
listing: json['listing'] == null
? null
: ListFilesResponse.fromJson(json['listing'] as Map<String, dynamic>),
);
_FilesState _$FilesStateFromJson(Map<String, dynamic> json) =>
$checkedCreate('_FilesState', json, ($checkedConvert) {
final val = _FilesState(
currentPath: $checkedConvert(
'currentPath',
(v) =>
(v as List<dynamic>?)?.map((e) => e as String).toList() ??
const <String>[],
),
listing: $checkedConvert(
'listing',
(v) => v == null
? null
: ListFilesResponse.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$FilesStateToJson(_FilesState instance) =>
<String, dynamic>{
@@ -7,15 +7,19 @@ part of 'grade_averages_state.dart';
// **************************************************************************
_GradeAveragesState _$GradeAveragesStateFromJson(Map<String, dynamic> json) =>
_GradeAveragesState(
gradingSystem: $enumDecode(
_$GradeAveragesGradingSystemEnumMap,
json['gradingSystem'],
),
grades: (json['grades'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
);
$checkedCreate('_GradeAveragesState', json, ($checkedConvert) {
final val = _GradeAveragesState(
gradingSystem: $checkedConvert(
'gradingSystem',
(v) => $enumDecode(_$GradeAveragesGradingSystemEnumMap, v),
),
grades: $checkedConvert(
'grades',
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
);
return val;
});
Map<String, dynamic> _$GradeAveragesStateToJson(
_GradeAveragesState instance,
@@ -7,13 +7,19 @@ part of 'holidays_state.dart';
// **************************************************************************
_HolidaysState _$HolidaysStateFromJson(Map<String, dynamic> json) =>
_HolidaysState(
showPastHolidays: json['showPastHolidays'] as bool,
showDisclaimer: json['showDisclaimer'] as bool,
holidays: (json['holidays'] as List<dynamic>)
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList(),
);
$checkedCreate('_HolidaysState', json, ($checkedConvert) {
final val = _HolidaysState(
showPastHolidays: $checkedConvert('showPastHolidays', (v) => v as bool),
showDisclaimer: $checkedConvert('showDisclaimer', (v) => v as bool),
holidays: $checkedConvert(
'holidays',
(v) => (v as List<dynamic>)
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$HolidaysStateToJson(_HolidaysState instance) =>
<String, dynamic>{
@@ -7,12 +7,18 @@ part of 'marianum_dates_state.dart';
// **************************************************************************
_MarianumDatesState _$MarianumDatesStateFromJson(Map<String, dynamic> json) =>
_MarianumDatesState(
showPastEvents: json['showPastEvents'] as bool,
events: (json['events'] as List<dynamic>)
.map((e) => MarianumDate.fromJson(e as Map<String, dynamic>))
.toList(),
);
$checkedCreate('_MarianumDatesState', json, ($checkedConvert) {
final val = _MarianumDatesState(
showPastEvents: $checkedConvert('showPastEvents', (v) => v as bool),
events: $checkedConvert(
'events',
(v) => (v as List<dynamic>)
.map((e) => MarianumDate.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$MarianumDatesStateToJson(_MarianumDatesState instance) =>
<String, dynamic>{
@@ -21,14 +27,17 @@ Map<String, dynamic> _$MarianumDatesStateToJson(_MarianumDatesState instance) =>
};
_MarianumDate _$MarianumDateFromJson(Map<String, dynamic> json) =>
_MarianumDate(
uid: json['uid'] as String,
title: json['title'] as String,
description: json['description'] as String?,
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
isAllDay: json['isAllDay'] as bool,
);
$checkedCreate('_MarianumDate', json, ($checkedConvert) {
final val = _MarianumDate(
uid: $checkedConvert('uid', (v) => v as String),
title: $checkedConvert('title', (v) => v as String),
description: $checkedConvert('description', (v) => v as String?),
start: $checkedConvert('start', (v) => DateTime.parse(v as String)),
end: $checkedConvert('end', (v) => DateTime.parse(v as String)),
isAllDay: $checkedConvert('isAllDay', (v) => v as bool),
);
return val;
});
Map<String, dynamic> _$MarianumDateToJson(_MarianumDate instance) =>
<String, dynamic>{
@@ -8,36 +8,49 @@ part of 'marianum_message_state.dart';
_MarianumMessageState _$MarianumMessageStateFromJson(
Map<String, dynamic> json,
) => _MarianumMessageState(
messageList: MarianumMessageList.fromJson(
json['messageList'] as Map<String, dynamic>,
),
);
) => $checkedCreate('_MarianumMessageState', json, ($checkedConvert) {
final val = _MarianumMessageState(
messageList: $checkedConvert(
'messageList',
(v) => MarianumMessageList.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$MarianumMessageStateToJson(
_MarianumMessageState instance,
) => <String, dynamic>{'messageList': instance.messageList};
_MarianumMessageList _$MarianumMessageListFromJson(Map<String, dynamic> json) =>
_MarianumMessageList(
base: json['base'] as String,
messages: (json['messages'] as List<dynamic>)
.map((e) => MarianumMessage.fromJson(e as Map<String, dynamic>))
.toList(),
);
$checkedCreate('_MarianumMessageList', json, ($checkedConvert) {
final val = _MarianumMessageList(
base: $checkedConvert('base', (v) => v as String),
messages: $checkedConvert(
'messages',
(v) => (v as List<dynamic>)
.map((e) => MarianumMessage.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$MarianumMessageListToJson(
_MarianumMessageList instance,
) => <String, dynamic>{'base': instance.base, 'messages': instance.messages};
_MarianumMessage _$MarianumMessageFromJson(Map<String, dynamic> json) =>
_MarianumMessage(
id: json['id'] as String? ?? '',
name: json['name'] as String,
date: json['date'] as String,
description: json['description'] as String? ?? '',
url: json['url'] as String,
);
$checkedCreate('_MarianumMessage', json, ($checkedConvert) {
final val = _MarianumMessage(
id: $checkedConvert('id', (v) => v as String? ?? ''),
name: $checkedConvert('name', (v) => v as String),
date: $checkedConvert('date', (v) => v as String),
description: $checkedConvert('description', (v) => v as String? ?? ''),
url: $checkedConvert('url', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$MarianumMessageToJson(_MarianumMessage instance) =>
<String, dynamic>{
@@ -8,24 +8,56 @@ part of 'nextcloud_capabilities_state.dart';
_NextcloudCapabilitiesState _$NextcloudCapabilitiesStateFromJson(
Map<String, dynamic> json,
) => _NextcloudCapabilitiesState(
apiEnabled: json['apiEnabled'] as bool? ?? false,
publicEnabled: json['publicEnabled'] as bool? ?? false,
publicMultipleLinks: json['publicMultipleLinks'] as bool? ?? false,
publicUploadEnabled: json['publicUploadEnabled'] as bool? ?? false,
publicPasswordEnforced: json['publicPasswordEnforced'] as bool? ?? false,
publicExpireEnabled: json['publicExpireEnabled'] as bool? ?? false,
publicExpireDays: (json['publicExpireDays'] as num?)?.toInt(),
publicExpireEnforced: json['publicExpireEnforced'] as bool? ?? false,
groupEnabled: json['groupEnabled'] as bool? ?? false,
resharing: json['resharing'] as bool? ?? false,
passwordMinLength: (json['passwordMinLength'] as num?)?.toInt(),
passwordEnforceUpperLower:
json['passwordEnforceUpperLower'] as bool? ?? false,
passwordEnforceNumeric: json['passwordEnforceNumeric'] as bool? ?? false,
passwordEnforceSpecial: json['passwordEnforceSpecial'] as bool? ?? false,
loaded: json['loaded'] as bool? ?? false,
);
) => $checkedCreate('_NextcloudCapabilitiesState', json, ($checkedConvert) {
final val = _NextcloudCapabilitiesState(
apiEnabled: $checkedConvert('apiEnabled', (v) => v as bool? ?? false),
publicEnabled: $checkedConvert('publicEnabled', (v) => v as bool? ?? false),
publicMultipleLinks: $checkedConvert(
'publicMultipleLinks',
(v) => v as bool? ?? false,
),
publicUploadEnabled: $checkedConvert(
'publicUploadEnabled',
(v) => v as bool? ?? false,
),
publicPasswordEnforced: $checkedConvert(
'publicPasswordEnforced',
(v) => v as bool? ?? false,
),
publicExpireEnabled: $checkedConvert(
'publicExpireEnabled',
(v) => v as bool? ?? false,
),
publicExpireDays: $checkedConvert(
'publicExpireDays',
(v) => (v as num?)?.toInt(),
),
publicExpireEnforced: $checkedConvert(
'publicExpireEnforced',
(v) => v as bool? ?? false,
),
groupEnabled: $checkedConvert('groupEnabled', (v) => v as bool? ?? false),
resharing: $checkedConvert('resharing', (v) => v as bool? ?? false),
passwordMinLength: $checkedConvert(
'passwordMinLength',
(v) => (v as num?)?.toInt(),
),
passwordEnforceUpperLower: $checkedConvert(
'passwordEnforceUpperLower',
(v) => v as bool? ?? false,
),
passwordEnforceNumeric: $checkedConvert(
'passwordEnforceNumeric',
(v) => v as bool? ?? false,
),
passwordEnforceSpecial: $checkedConvert(
'passwordEnforceSpecial',
(v) => v as bool? ?? false,
),
loaded: $checkedConvert('loaded', (v) => v as bool? ?? false),
);
return val;
});
Map<String, dynamic> _$NextcloudCapabilitiesStateToJson(
_NextcloudCapabilitiesState instance,
@@ -7,11 +7,17 @@ part of 'ticker_page_state.dart';
// **************************************************************************
_TickerPageState _$TickerPageStateFromJson(Map<String, dynamic> json) =>
_TickerPageState(
page: json['page'] == null
? null
: TickerPageResponse.fromJson(json['page'] as Map<String, dynamic>),
);
$checkedCreate('_TickerPageState', json, ($checkedConvert) {
final val = _TickerPageState(
page: $checkedConvert(
'page',
(v) => v == null
? null
: TickerPageResponse.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$TickerPageStateToJson(_TickerPageState instance) =>
<String, dynamic>{'page': instance.page};
@@ -6,15 +6,25 @@ part of 'ticker_state.dart';
// JsonSerializableGenerator
// **************************************************************************
_TickerState _$TickerStateFromJson(Map<String, dynamic> json) => _TickerState(
ticker: json['ticker'] == null
? null
: TickerResponse.fromJson(json['ticker'] as Map<String, dynamic>),
nav: json['nav'] == null
? null
: TickerNavResponse.fromJson(json['nav'] as Map<String, dynamic>),
selectedSlug: json['selectedSlug'] as String?,
);
_TickerState _$TickerStateFromJson(Map<String, dynamic> json) =>
$checkedCreate('_TickerState', json, ($checkedConvert) {
final val = _TickerState(
ticker: $checkedConvert(
'ticker',
(v) => v == null
? null
: TickerResponse.fromJson(v as Map<String, dynamic>),
),
nav: $checkedConvert(
'nav',
(v) => v == null
? null
: TickerNavResponse.fromJson(v as Map<String, dynamic>),
),
selectedSlug: $checkedConvert('selectedSlug', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$TickerStateToJson(_TickerState instance) =>
<String, dynamic>{
@@ -6,56 +6,74 @@ part of 'timetable_state.dart';
// JsonSerializableGenerator
// **************************************************************************
_TimetableState _$TimetableStateFromJson(Map<String, dynamic> json) =>
_TimetableState(
weekCache:
(json['weekCache'] as Map<String, dynamic>?)?.map(
_TimetableState _$TimetableStateFromJson(
Map<String, dynamic> json,
) => $checkedCreate('_TimetableState', json, ($checkedConvert) {
final val = _TimetableState(
weekCache: $checkedConvert(
'weekCache',
(v) =>
(v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
k,
TimetableGetWeekResponse.fromJson(e as Map<String, dynamic>),
),
) ??
const <String, TimetableGetWeekResponse>{},
rooms: json['rooms'] == null
),
rooms: $checkedConvert(
'rooms',
(v) => v == null
? null
: TimetableGetRoomsResponse.fromJson(
json['rooms'] as Map<String, dynamic>,
),
subjects: json['subjects'] == null
: TimetableGetRoomsResponse.fromJson(v as Map<String, dynamic>),
),
subjects: $checkedConvert(
'subjects',
(v) => v == null
? null
: TimetableGetSubjectsResponse.fromJson(
json['subjects'] as Map<String, dynamic>,
),
schoolHolidays: json['schoolHolidays'] == null
: TimetableGetSubjectsResponse.fromJson(v as Map<String, dynamic>),
),
schoolHolidays: $checkedConvert(
'schoolHolidays',
(v) => v == null
? null
: TimetableGetHolidaysResponse.fromJson(
json['schoolHolidays'] as Map<String, dynamic>,
),
schoolyear: json['schoolyear'] == null
: TimetableGetHolidaysResponse.fromJson(v as Map<String, dynamic>),
),
schoolyear: $checkedConvert(
'schoolyear',
(v) => v == null
? null
: TimetableGetSchoolyearResponse.fromJson(
json['schoolyear'] as Map<String, dynamic>,
),
timegrid: json['timegrid'] == null
: TimetableGetSchoolyearResponse.fromJson(v as Map<String, dynamic>),
),
timegrid: $checkedConvert(
'timegrid',
(v) => v == null
? null
: TimetableGetTimegridResponse.fromJson(
json['timegrid'] as Map<String, dynamic>,
),
customEvents: json['customEvents'] == null
: TimetableGetTimegridResponse.fromJson(v as Map<String, dynamic>),
),
customEvents: $checkedConvert(
'customEvents',
(v) => v == null
? null
: GetCustomTimetableEventResponse.fromJson(
json['customEvents'] as Map<String, dynamic>,
),
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
dataVersion: (json['dataVersion'] as num?)?.toInt() ?? 0,
accessibleStartDate: json['accessibleStartDate'] == null
? null
: DateTime.parse(json['accessibleStartDate'] as String),
accessibleEndDate: json['accessibleEndDate'] == null
? null
: DateTime.parse(json['accessibleEndDate'] as String),
);
: GetCustomTimetableEventResponse.fromJson(v as Map<String, dynamic>),
),
startDate: $checkedConvert('startDate', (v) => DateTime.parse(v as String)),
endDate: $checkedConvert('endDate', (v) => DateTime.parse(v as String)),
dataVersion: $checkedConvert(
'dataVersion',
(v) => (v as num?)?.toInt() ?? 0,
),
accessibleStartDate: $checkedConvert(
'accessibleStartDate',
(v) => v == null ? null : DateTime.parse(v as String),
),
accessibleEndDate: $checkedConvert(
'accessibleEndDate',
(v) => v == null ? null : DateTime.parse(v as String),
),
);
return val;
});
Map<String, dynamic> _$TimetableStateToJson(_TimetableState instance) =>
<String, dynamic>{