From b7b2d404d035f4d83a5f3a2dc66055551dd022d3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 14:05:21 +0300 Subject: Fix PlayerApiClient const constructor and Media.tags deserialization (d9+e9) - Remove const from PlayerApiClient constructor (Uri is not const-constructable) - Replace .cast() with .whereType().toList() in Media.fromJson to silently drop non-string and null tag elements instead of throwing TypeError - Add regression test: tags [1, 'valid', null] deserialized as ['valid'] Co-Authored-By: Claude Opus 4.7 --- player-android/lib/api/player_api_client.dart | 4 +++- player-android/lib/models/media.dart | 5 ++++- player-android/test/models_test.dart | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 3031c73..eb438f4 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -6,7 +6,9 @@ class PlayerApiClient { final Uri baseUrl; final String bearerToken; - const PlayerApiClient({required this.baseUrl, required this.bearerToken}); + // Normal (non-const) constructor: Uri is not const-constructable, so the + // constructor must not be declared const. + PlayerApiClient({required this.baseUrl, required this.bearerToken}); Future bootstrap({required String username, required String password}) => throw UnimplementedError(); Future login({required String username, required String password}) => throw UnimplementedError(); diff --git a/player-android/lib/models/media.dart b/player-android/lib/models/media.dart index 7330bca..19c57b8 100644 --- a/player-android/lib/models/media.dart +++ b/player-android/lib/models/media.dart @@ -10,7 +10,10 @@ class Media { const Media({required this.id, required this.setId, required this.relPath, required this.fileName, required this.absPath, required this.type, required this.duration, required this.codec, required this.resolution, required this.bitrate, required this.fileSizeBytes, required this.width, required this.height, required this.thumbnailPath, required this.playCount, this.favorite = false, this.tags = const [], this.deletedAt, this.createdAt}); - factory Media.fromJson(Map json) => Media(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, relPath: json['rel_path'] as String? ?? '', fileName: json['file_name'] as String? ?? '', absPath: json['abs_path'] as String? ?? '', type: json['type'] as String? ?? '', duration: (json['duration'] as num?)?.toDouble() ?? 0, codec: json['codec'] as String? ?? '', resolution: json['resolution'] as String? ?? '', bitrate: json['bitrate'] as int? ?? 0, fileSizeBytes: json['file_size_bytes'] as int? ?? 0, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, thumbnailPath: json['thumbnail_path'] as String? ?? '', playCount: json['play_count'] as int? ?? 0, favorite: json['favorite'] as bool? ?? false, tags: (json['tags'] as List? ?? const []).cast(), deletedAt: dateTimeFromJson(json['deleted_at']), createdAt: dateTimeFromJson(json['created_at'])); + factory Media.fromJson(Map json) => Media(id: json['id'] as int? ?? 0, setId: json['set_id'] as int? ?? 0, relPath: json['rel_path'] as String? ?? '', fileName: json['file_name'] as String? ?? '', absPath: json['abs_path'] as String? ?? '', type: json['type'] as String? ?? '', duration: (json['duration'] as num?)?.toDouble() ?? 0, codec: json['codec'] as String? ?? '', resolution: json['resolution'] as String? ?? '', bitrate: json['bitrate'] as int? ?? 0, fileSizeBytes: json['file_size_bytes'] as int? ?? 0, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, thumbnailPath: json['thumbnail_path'] as String? ?? '', playCount: json['play_count'] as int? ?? 0, favorite: json['favorite'] as bool? ?? false, // Safe tag deserialization: keep only elements that are already Strings, +// silently dropping ints, nulls, or other unexpected types. This tolerates +// malformed server responses without throwing a TypeError at runtime. +tags: (json['tags'] as List? ?? []).whereType().toList(), deletedAt: dateTimeFromJson(json['deleted_at']), createdAt: dateTimeFromJson(json['created_at'])); Map toJson() => {'id': id, 'set_id': setId, 'rel_path': relPath, 'file_name': fileName, 'abs_path': absPath, 'type': type, 'duration': duration, 'codec': codec, 'resolution': resolution, 'bitrate': bitrate, 'file_size_bytes': fileSizeBytes, 'width': width, 'height': height, 'thumbnail_path': thumbnailPath, 'play_count': playCount, 'favorite': favorite, 'tags': tags, 'deleted_at': dateTimeToJson(deletedAt), 'created_at': dateTimeToJson(createdAt)}; } diff --git a/player-android/test/models_test.dart b/player-android/test/models_test.dart index 22dc190..84fbf62 100644 --- a/player-android/test/models_test.dart +++ b/player-android/test/models_test.dart @@ -126,6 +126,15 @@ void main() { expect(media.deletedAt, isNull); expect(media.createdAt, isNull); }); + + // Regression test for e9: non-string and null elements in the tags list + // must be silently dropped rather than causing a TypeError at runtime. + test('fromJson drops non-string and null tag elements', () { + final media = Media.fromJson({'tags': [1, 'valid', null]}); + + // Only 'valid' survives: the integer 1 and null are filtered out. + expect(media.tags, ['valid']); + }); }); // --------------------------------------------------------------------------- -- cgit v1.2.3