From 98f4fe85d7b6fcc57ad03dd0f385e4fe3caf1e30 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:30:27 +0900 Subject: [PATCH 1/6] Fix crash when publishing with deprecated DegradationPreference.disabled toRTCType built a map without an entry for disabled and force unwrapped the lookup, so an explicit disabled preference threw a null check error during publish. WebRTC has renamed DISABLED to MAINTAIN_FRAMERATE_AND_RESOLUTION and defines the old name as an alias, so map it accordingly. The conversion is now an exhaustive switch, which turns any future enum addition into a compile error instead of a runtime crash. --- .../degradation-preference-disabled-crash | 1 + lib/src/extensions.dart | 17 ++++---- lib/src/options.dart | 2 +- .../degradation_preference_rtc_test.dart | 39 +++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 .changes/degradation-preference-disabled-crash create mode 100644 test/types/degradation_preference_rtc_test.dart diff --git a/.changes/degradation-preference-disabled-crash b/.changes/degradation-preference-disabled-crash new file mode 100644 index 000000000..8087a866c --- /dev/null +++ b/.changes/degradation-preference-disabled-crash @@ -0,0 +1 @@ +patch type="fixed" "Publishing a video track with the deprecated DegradationPreference.disabled no longer crashes, it now maps to maintainFramerateAndResolution as WebRTC defines it" diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 11ecc28fe..71347a3e1 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -266,13 +266,16 @@ extension ParticipantTypeExt on lk_models.ParticipantInfo_Kind { } extension DegradationPreferenceExt on DegradationPreference { - rtc.RTCDegradationPreference toRTCType() => { - DegradationPreference.maintainFramerate: rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE, - DegradationPreference.maintainResolution: rtc.RTCDegradationPreference.MAINTAIN_RESOLUTION, - DegradationPreference.balanced: rtc.RTCDegradationPreference.BALANCED, - DegradationPreference.maintainFramerateAndResolution: - rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION, - }[this]!; + rtc.RTCDegradationPreference toRTCType() => switch (this) { + // WebRTC defines DISABLED as an alias for MAINTAIN_FRAMERATE_AND_RESOLUTION + // ignore: deprecated_member_use_from_same_package + DegradationPreference.disabled => rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION, + DegradationPreference.maintainFramerate => rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE, + DegradationPreference.maintainResolution => rtc.RTCDegradationPreference.MAINTAIN_RESOLUTION, + DegradationPreference.balanced => rtc.RTCDegradationPreference.BALANCED, + DegradationPreference.maintainFramerateAndResolution => + rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION, + }; } extension RoomOptionsEx on RoomOptions { diff --git a/lib/src/options.dart b/lib/src/options.dart index 25b807eed..448fe6bbf 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -340,7 +340,7 @@ class RoomOptions { } enum DegradationPreference { - @Deprecated('DISABLED is Deprecated for DegradationPreference') + @Deprecated('Use maintainFramerateAndResolution instead, WebRTC defines disabled as an alias for it') disabled, maintainFramerate, maintainResolution, diff --git a/test/types/degradation_preference_rtc_test.dart b/test/types/degradation_preference_rtc_test.dart new file mode 100644 index 000000000..90ba55dae --- /dev/null +++ b/test/types/degradation_preference_rtc_test.dart @@ -0,0 +1,39 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/extensions.dart'; +import 'package:livekit_client/src/options.dart'; + +void main() { + group('DegradationPreference.toRTCType', () { + test('converts every value without throwing', () { + for (final preference in DegradationPreference.values) { + expect(() => preference.toRTCType(), returnsNormally); + } + }); + + test('deprecated disabled maps to maintain framerate and resolution', () { + // WebRTC defines DISABLED as an alias for MAINTAIN_FRAMERATE_AND_RESOLUTION + expect( + // ignore: deprecated_member_use_from_same_package + DegradationPreference.disabled.toRTCType(), + rtc.RTCDegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION, + ); + }); + }); +} From f5fe4856daa54cabd6f632ef24ab6bf23489f3d4 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:34:59 +0900 Subject: [PATCH 2/6] Migrate enum conversions from map lookups to switch expressions The map-with-null-assertion pattern crashes at runtime when a value is missing from the map, which is how the DegradationPreference.disabled bug happened. Switch expressions over Dart enums are compile time exhaustive, so a future enum addition becomes an analyzer error instead. Protobuf enums are classes rather than Dart enums, so their switches keep a wildcard arm. Where the old code force unwrapped the lookup, the wildcard now falls back to a safe default instead of crashing on values from newer servers. DisconnectReason was already affected in practice, the proto defines 17 reasons but only 8 were mapped, so a server sending ROOM_CLOSED or MIGRATION in a leave request crashed disconnect handling. --- .changes/proto-enum-conversion-fallbacks | 1 + lib/src/extensions.dart | 231 +++++++++++------------ lib/src/support/native_audio.dart | 54 +++--- lib/src/track/options.dart | 8 +- 4 files changed, 146 insertions(+), 148 deletions(-) create mode 100644 .changes/proto-enum-conversion-fallbacks diff --git a/.changes/proto-enum-conversion-fallbacks b/.changes/proto-enum-conversion-fallbacks new file mode 100644 index 000000000..aaf7c1b29 --- /dev/null +++ b/.changes/proto-enum-conversion-fallbacks @@ -0,0 +1 @@ +patch type="fixed" "Protobuf enum conversions no longer crash on values from newer servers, unrecognized values now fall back to a safe default (for example a new DisconnectReason maps to unknown instead of failing disconnect handling)" diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 71347a3e1..171dfe799 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -27,10 +27,11 @@ import 'proto/livekit_rtc.pb.dart' as lk_rtc; import 'types/other.dart'; extension DataPacketKindExt on lk_models.DataPacket_Kind { - Reliability toSDKType() => { - lk_models.DataPacket_Kind.RELIABLE: Reliability.reliable, - lk_models.DataPacket_Kind.LOSSY: Reliability.lossy, - }[this]!; + Reliability toSDKType() => switch (this) { + lk_models.DataPacket_Kind.RELIABLE => Reliability.reliable, + lk_models.DataPacket_Kind.LOSSY => Reliability.lossy, + _ => Reliability.lossy, + }; } extension LiveKitEventExt on Iterable> { @@ -54,23 +55,23 @@ extension ObjectExt on Object { } extension ProtocolVersionExt on ProtocolVersion { - String toStringValue() => { - ProtocolVersion.v2: '2', - ProtocolVersion.v3: '3', - ProtocolVersion.v4: '4', - ProtocolVersion.v5: '5', - ProtocolVersion.v6: '6', - ProtocolVersion.v7: '7', - ProtocolVersion.v8: '8', - ProtocolVersion.v9: '9', - ProtocolVersion.v10: '10', - ProtocolVersion.v11: '11', - ProtocolVersion.v12: '12', - ProtocolVersion.v13: '13', - ProtocolVersion.v14: '14', - ProtocolVersion.v15: '15', - ProtocolVersion.v16: '16', - }[this]!; + String toStringValue() => switch (this) { + ProtocolVersion.v2 => '2', + ProtocolVersion.v3 => '3', + ProtocolVersion.v4 => '4', + ProtocolVersion.v5 => '5', + ProtocolVersion.v6 => '6', + ProtocolVersion.v7 => '7', + ProtocolVersion.v8 => '8', + ProtocolVersion.v9 => '9', + ProtocolVersion.v10 => '10', + ProtocolVersion.v11 => '11', + ProtocolVersion.v12 => '12', + ProtocolVersion.v13 => '13', + ProtocolVersion.v14 => '14', + ProtocolVersion.v15 => '15', + ProtocolVersion.v16 => '16', + }; } extension ClientProtocolVersionExt on ClientProtocolVersion { @@ -80,10 +81,10 @@ extension ClientProtocolVersionExt on ClientProtocolVersion { } extension ReliabilityExt on Reliability { - lk_models.DataPacket_Kind toPBType() => { - Reliability.reliable: lk_models.DataPacket_Kind.RELIABLE, - Reliability.lossy: lk_models.DataPacket_Kind.LOSSY, - }[this]!; + lk_models.DataPacket_Kind toPBType() => switch (this) { + Reliability.reliable => lk_models.DataPacket_Kind.RELIABLE, + Reliability.lossy => lk_models.DataPacket_Kind.LOSSY, + }; } extension RTCDataChannelExt on rtc.RTCDataChannel { @@ -118,10 +119,10 @@ extension RTCPeerConnectionStateExt on rtc.RTCPeerConnectionState { } extension RTCIceTransportPolicyExt on RTCIceTransportPolicy { - String toStringValue() => { - RTCIceTransportPolicy.all: 'all', - RTCIceTransportPolicy.relay: 'relay', - }[this]!; + String toStringValue() => switch (this) { + RTCIceTransportPolicy.all => 'all', + RTCIceTransportPolicy.relay => 'relay', + }; } // not so neat to directly expose protobuf types so we @@ -139,86 +140,81 @@ extension SessionDescriptionExt on lk_rtc.SessionDescription { } extension ConnectionQualityExt on lk_models.ConnectionQuality { - ConnectionQuality toLKType() => - { - lk_models.ConnectionQuality.LOST: ConnectionQuality.lost, - lk_models.ConnectionQuality.POOR: ConnectionQuality.poor, - lk_models.ConnectionQuality.GOOD: ConnectionQuality.good, - lk_models.ConnectionQuality.EXCELLENT: ConnectionQuality.excellent, - }[this] ?? - ConnectionQuality.unknown; + ConnectionQuality toLKType() => switch (this) { + lk_models.ConnectionQuality.LOST => ConnectionQuality.lost, + lk_models.ConnectionQuality.POOR => ConnectionQuality.poor, + lk_models.ConnectionQuality.GOOD => ConnectionQuality.good, + lk_models.ConnectionQuality.EXCELLENT => ConnectionQuality.excellent, + _ => ConnectionQuality.unknown, + }; } extension VideoQualityExt on lk_models.VideoQuality { - VideoQuality toLKType() => - { - lk_models.VideoQuality.HIGH: VideoQuality.HIGH, - lk_models.VideoQuality.MEDIUM: VideoQuality.MEDIUM, - lk_models.VideoQuality.LOW: VideoQuality.LOW, - }[this] ?? - VideoQuality.LOW; - - String toRid() => { - lk_models.VideoQuality.HIGH: 'f', - lk_models.VideoQuality.MEDIUM: 'h', - lk_models.VideoQuality.LOW: 'q', - }[this]!; + VideoQuality toLKType() => switch (this) { + lk_models.VideoQuality.HIGH => VideoQuality.HIGH, + lk_models.VideoQuality.MEDIUM => VideoQuality.MEDIUM, + lk_models.VideoQuality.LOW => VideoQuality.LOW, + _ => VideoQuality.LOW, + }; + + String toRid() => switch (this) { + lk_models.VideoQuality.HIGH => 'f', + lk_models.VideoQuality.MEDIUM => 'h', + lk_models.VideoQuality.LOW => 'q', + _ => 'q', + }; } extension PBVideoQualityExt on VideoQuality { - lk_models.VideoQuality toPBType() => { - VideoQuality.HIGH: lk_models.VideoQuality.HIGH, - VideoQuality.MEDIUM: lk_models.VideoQuality.MEDIUM, - VideoQuality.LOW: lk_models.VideoQuality.LOW, - }[this]!; + lk_models.VideoQuality toPBType() => switch (this) { + VideoQuality.HIGH => lk_models.VideoQuality.HIGH, + VideoQuality.MEDIUM => lk_models.VideoQuality.MEDIUM, + VideoQuality.LOW => lk_models.VideoQuality.LOW, + }; } extension TrackTypeExt on lk_models.TrackType { - TrackType toLKType() => - { - lk_models.TrackType.AUDIO: TrackType.AUDIO, - lk_models.TrackType.VIDEO: TrackType.VIDEO, - lk_models.TrackType.DATA: TrackType.DATA, - }[this] ?? - TrackType.AUDIO; + TrackType toLKType() => switch (this) { + lk_models.TrackType.AUDIO => TrackType.AUDIO, + lk_models.TrackType.VIDEO => TrackType.VIDEO, + lk_models.TrackType.DATA => TrackType.DATA, + _ => TrackType.AUDIO, + }; } extension PBTrackTypeExt on TrackType { - lk_models.TrackType toPBType() => { - TrackType.AUDIO: lk_models.TrackType.AUDIO, - TrackType.VIDEO: lk_models.TrackType.VIDEO, - TrackType.DATA: lk_models.TrackType.DATA, - }[this]!; + lk_models.TrackType toPBType() => switch (this) { + TrackType.AUDIO => lk_models.TrackType.AUDIO, + TrackType.VIDEO => lk_models.TrackType.VIDEO, + TrackType.DATA => lk_models.TrackType.DATA, + }; } extension PBTrackSourceExt on lk_models.TrackSource { - TrackSource toLKType() => - { - lk_models.TrackSource.CAMERA: TrackSource.camera, - lk_models.TrackSource.MICROPHONE: TrackSource.microphone, - lk_models.TrackSource.SCREEN_SHARE: TrackSource.screenShareVideo, - lk_models.TrackSource.SCREEN_SHARE_AUDIO: TrackSource.screenShareAudio, - }[this] ?? - TrackSource.unknown; + TrackSource toLKType() => switch (this) { + lk_models.TrackSource.CAMERA => TrackSource.camera, + lk_models.TrackSource.MICROPHONE => TrackSource.microphone, + lk_models.TrackSource.SCREEN_SHARE => TrackSource.screenShareVideo, + lk_models.TrackSource.SCREEN_SHARE_AUDIO => TrackSource.screenShareAudio, + _ => TrackSource.unknown, + }; } extension LKTrackSourceExt on TrackSource { - lk_models.TrackSource toPBType() => - { - TrackSource.camera: lk_models.TrackSource.CAMERA, - TrackSource.microphone: lk_models.TrackSource.MICROPHONE, - TrackSource.screenShareVideo: lk_models.TrackSource.SCREEN_SHARE, - TrackSource.screenShareAudio: lk_models.TrackSource.SCREEN_SHARE_AUDIO, - }[this] ?? - lk_models.TrackSource.UNKNOWN; + lk_models.TrackSource toPBType() => switch (this) { + TrackSource.camera => lk_models.TrackSource.CAMERA, + TrackSource.microphone => lk_models.TrackSource.MICROPHONE, + TrackSource.screenShareVideo => lk_models.TrackSource.SCREEN_SHARE, + TrackSource.screenShareAudio => lk_models.TrackSource.SCREEN_SHARE_AUDIO, + TrackSource.unknown => lk_models.TrackSource.UNKNOWN, + }; } extension PBStreamStateExt on lk_rtc.StreamState { - StreamState toLKType() => - { - lk_rtc.StreamState.ACTIVE: StreamState.active, - }[this] ?? - StreamState.paused; + StreamState toLKType() => switch (this) { + lk_rtc.StreamState.ACTIVE => StreamState.active, + _ => StreamState.paused, + }; } extension ParticipantTrackPermissionExt on ParticipantTrackPermission { @@ -235,34 +231,37 @@ extension WidgetsBindingCompatible on WidgetsBinding { } extension EncryptionTypeExt on lk_models.Encryption_Type { - EncryptionType toLkType() => { - lk_models.Encryption_Type.NONE: EncryptionType.kNone, - lk_models.Encryption_Type.GCM: EncryptionType.kGcm, - lk_models.Encryption_Type.CUSTOM: EncryptionType.kCustom, - }[this]!; + EncryptionType toLkType() => switch (this) { + lk_models.Encryption_Type.NONE => EncryptionType.kNone, + lk_models.Encryption_Type.GCM => EncryptionType.kGcm, + lk_models.Encryption_Type.CUSTOM => EncryptionType.kCustom, + _ => EncryptionType.kNone, + }; } extension DisconnectReasonExt on lk_models.DisconnectReason { - DisconnectReason toSDKType() => { - lk_models.DisconnectReason.UNKNOWN_REASON: DisconnectReason.unknown, - lk_models.DisconnectReason.CLIENT_INITIATED: DisconnectReason.clientInitiated, - lk_models.DisconnectReason.DUPLICATE_IDENTITY: DisconnectReason.duplicateIdentity, - lk_models.DisconnectReason.SERVER_SHUTDOWN: DisconnectReason.serverShutdown, - lk_models.DisconnectReason.PARTICIPANT_REMOVED: DisconnectReason.participantRemoved, - lk_models.DisconnectReason.ROOM_DELETED: DisconnectReason.roomDeleted, - lk_models.DisconnectReason.STATE_MISMATCH: DisconnectReason.stateMismatch, - lk_models.DisconnectReason.JOIN_FAILURE: DisconnectReason.joinFailure, - }[this]!; + DisconnectReason toSDKType() => switch (this) { + lk_models.DisconnectReason.UNKNOWN_REASON => DisconnectReason.unknown, + lk_models.DisconnectReason.CLIENT_INITIATED => DisconnectReason.clientInitiated, + lk_models.DisconnectReason.DUPLICATE_IDENTITY => DisconnectReason.duplicateIdentity, + lk_models.DisconnectReason.SERVER_SHUTDOWN => DisconnectReason.serverShutdown, + lk_models.DisconnectReason.PARTICIPANT_REMOVED => DisconnectReason.participantRemoved, + lk_models.DisconnectReason.ROOM_DELETED => DisconnectReason.roomDeleted, + lk_models.DisconnectReason.STATE_MISMATCH => DisconnectReason.stateMismatch, + lk_models.DisconnectReason.JOIN_FAILURE => DisconnectReason.joinFailure, + _ => DisconnectReason.unknown, + }; } extension ParticipantTypeExt on lk_models.ParticipantInfo_Kind { - ParticipantKind toLKType() => { - lk_models.ParticipantInfo_Kind.STANDARD: ParticipantKind.STANDARD, - lk_models.ParticipantInfo_Kind.INGRESS: ParticipantKind.INGRESS, - lk_models.ParticipantInfo_Kind.EGRESS: ParticipantKind.EGRESS, - lk_models.ParticipantInfo_Kind.SIP: ParticipantKind.SIP, - lk_models.ParticipantInfo_Kind.AGENT: ParticipantKind.AGENT, - }[this]!; + ParticipantKind toLKType() => switch (this) { + lk_models.ParticipantInfo_Kind.STANDARD => ParticipantKind.STANDARD, + lk_models.ParticipantInfo_Kind.INGRESS => ParticipantKind.INGRESS, + lk_models.ParticipantInfo_Kind.EGRESS => ParticipantKind.EGRESS, + lk_models.ParticipantInfo_Kind.SIP => ParticipantKind.SIP, + lk_models.ParticipantInfo_Kind.AGENT => ParticipantKind.AGENT, + _ => ParticipantKind.STANDARD, + }; } extension DegradationPreferenceExt on DegradationPreference { @@ -282,12 +281,10 @@ extension RoomOptionsEx on RoomOptions { lk_models.Encryption_Type get lkEncryptionType { // ignore: deprecated_member_use_from_same_package final e2ee = encryption ?? e2eeOptions; - return (e2ee != null) - ? { - EncryptionType.kNone: lk_models.Encryption_Type.NONE, - EncryptionType.kGcm: lk_models.Encryption_Type.GCM, - EncryptionType.kCustom: lk_models.Encryption_Type.CUSTOM, - }[e2ee.encryptionType]! - : lk_models.Encryption_Type.NONE; + return switch (e2ee?.encryptionType) { + EncryptionType.kNone || null => lk_models.Encryption_Type.NONE, + EncryptionType.kGcm => lk_models.Encryption_Type.GCM, + EncryptionType.kCustom => lk_models.Encryption_Type.CUSTOM, + }; } } diff --git a/lib/src/support/native_audio.dart b/lib/src/support/native_audio.dart index 0cce554f0..e09fa8e08 100644 --- a/lib/src/support/native_audio.dart +++ b/lib/src/support/native_audio.dart @@ -16,39 +16,39 @@ import '../audio/audio_session.dart' show AppleAudioCategory, AppleAudioCategory import 'value_or_absent.dart'; extension AppleAudioCategoryExt on AppleAudioCategory { - String toStringValue() => { - AppleAudioCategory.soloAmbient: 'soloAmbient', - AppleAudioCategory.playback: 'playback', - AppleAudioCategory.record: 'record', - AppleAudioCategory.playAndRecord: 'playAndRecord', - AppleAudioCategory.multiRoute: 'multiRoute', - }[this]!; + String toStringValue() => switch (this) { + AppleAudioCategory.soloAmbient => 'soloAmbient', + AppleAudioCategory.playback => 'playback', + AppleAudioCategory.record => 'record', + AppleAudioCategory.playAndRecord => 'playAndRecord', + AppleAudioCategory.multiRoute => 'multiRoute', + }; } extension AppleAudioCategoryOptionExt on AppleAudioCategoryOption { - String toStringValue() => { - AppleAudioCategoryOption.mixWithOthers: 'mixWithOthers', - AppleAudioCategoryOption.duckOthers: 'duckOthers', - AppleAudioCategoryOption.interruptSpokenAudioAndMixWithOthers: 'interruptSpokenAudioAndMixWithOthers', - AppleAudioCategoryOption.allowBluetooth: 'allowBluetooth', - AppleAudioCategoryOption.allowBluetoothA2DP: 'allowBluetoothA2DP', - AppleAudioCategoryOption.allowAirPlay: 'allowAirPlay', - AppleAudioCategoryOption.defaultToSpeaker: 'defaultToSpeaker', - }[this]!; + String toStringValue() => switch (this) { + AppleAudioCategoryOption.mixWithOthers => 'mixWithOthers', + AppleAudioCategoryOption.duckOthers => 'duckOthers', + AppleAudioCategoryOption.interruptSpokenAudioAndMixWithOthers => 'interruptSpokenAudioAndMixWithOthers', + AppleAudioCategoryOption.allowBluetooth => 'allowBluetooth', + AppleAudioCategoryOption.allowBluetoothA2DP => 'allowBluetoothA2DP', + AppleAudioCategoryOption.allowAirPlay => 'allowAirPlay', + AppleAudioCategoryOption.defaultToSpeaker => 'defaultToSpeaker', + }; } extension AppleAudioModeExt on AppleAudioMode { - String toStringValue() => { - AppleAudioMode.default_: 'default', - AppleAudioMode.gameChat: 'gameChat', - AppleAudioMode.measurement: 'measurement', - AppleAudioMode.moviePlayback: 'moviePlayback', - AppleAudioMode.spokenAudio: 'spokenAudio', - AppleAudioMode.videoChat: 'videoChat', - AppleAudioMode.videoRecording: 'videoRecording', - AppleAudioMode.voiceChat: 'voiceChat', - AppleAudioMode.voicePrompt: 'voicePrompt', - }[this]!; + String toStringValue() => switch (this) { + AppleAudioMode.default_ => 'default', + AppleAudioMode.gameChat => 'gameChat', + AppleAudioMode.measurement => 'measurement', + AppleAudioMode.moviePlayback => 'moviePlayback', + AppleAudioMode.spokenAudio => 'spokenAudio', + AppleAudioMode.videoChat => 'videoChat', + AppleAudioMode.videoRecording => 'videoRecording', + AppleAudioMode.voiceChat => 'voiceChat', + AppleAudioMode.voicePrompt => 'voicePrompt', + }; } class NativeAudioConfiguration { diff --git a/lib/src/track/options.dart b/lib/src/track/options.dart index 7ada778a0..3b1b8f0e7 100644 --- a/lib/src/track/options.dart +++ b/lib/src/track/options.dart @@ -37,10 +37,10 @@ enum CameraExposureMode { auto, locked } /// Convenience extension for [CameraPosition]. extension CameraPositionExt on CameraPosition { /// Return a [CameraPosition] which front and back is switched. - CameraPosition switched() => { - CameraPosition.front: CameraPosition.back, - CameraPosition.back: CameraPosition.front, - }[this]!; + CameraPosition switched() => switch (this) { + CameraPosition.front => CameraPosition.back, + CameraPosition.back => CameraPosition.front, + }; } /// Options used when creating a [LocalVideoTrack] that captures the camera. From fcdd4b143fd0a314ec4084edc63037ece9299b3c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:41:58 +0900 Subject: [PATCH 3/6] Reword changesets --- .changes/degradation-preference-disabled-crash | 1 - .changes/degradation-preference-disabled-mapping | 1 + .changes/enum-conversion-fallbacks | 1 + .changes/proto-enum-conversion-fallbacks | 1 - 4 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 .changes/degradation-preference-disabled-crash create mode 100644 .changes/degradation-preference-disabled-mapping create mode 100644 .changes/enum-conversion-fallbacks delete mode 100644 .changes/proto-enum-conversion-fallbacks diff --git a/.changes/degradation-preference-disabled-crash b/.changes/degradation-preference-disabled-crash deleted file mode 100644 index 8087a866c..000000000 --- a/.changes/degradation-preference-disabled-crash +++ /dev/null @@ -1 +0,0 @@ -patch type="fixed" "Publishing a video track with the deprecated DegradationPreference.disabled no longer crashes, it now maps to maintainFramerateAndResolution as WebRTC defines it" diff --git a/.changes/degradation-preference-disabled-mapping b/.changes/degradation-preference-disabled-mapping new file mode 100644 index 000000000..7ca6a803b --- /dev/null +++ b/.changes/degradation-preference-disabled-mapping @@ -0,0 +1 @@ +patch type="fixed" "The deprecated DegradationPreference.disabled is now handled as maintainFramerateAndResolution, matching how WebRTC defines it" diff --git a/.changes/enum-conversion-fallbacks b/.changes/enum-conversion-fallbacks new file mode 100644 index 000000000..38657cc4c --- /dev/null +++ b/.changes/enum-conversion-fallbacks @@ -0,0 +1 @@ +patch type="changed" "Enum conversions are more robust, unrecognized protobuf values from newer servers now fall back to safe defaults (for example a new DisconnectReason maps to unknown)" diff --git a/.changes/proto-enum-conversion-fallbacks b/.changes/proto-enum-conversion-fallbacks deleted file mode 100644 index aaf7c1b29..000000000 --- a/.changes/proto-enum-conversion-fallbacks +++ /dev/null @@ -1 +0,0 @@ -patch type="fixed" "Protobuf enum conversions no longer crash on values from newer servers, unrecognized values now fall back to a safe default (for example a new DisconnectReason maps to unknown instead of failing disconnect handling)" From a070889c241006a4de3088e7e29a61759a169d30 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:45:00 +0900 Subject: [PATCH 4/6] Sort imports in degradation preference test --- test/types/degradation_preference_rtc_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/types/degradation_preference_rtc_test.dart b/test/types/degradation_preference_rtc_test.dart index 90ba55dae..afd4f0400 100644 --- a/test/types/degradation_preference_rtc_test.dart +++ b/test/types/degradation_preference_rtc_test.dart @@ -12,9 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; - import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:livekit_client/src/extensions.dart'; import 'package:livekit_client/src/options.dart'; From 1f30bfdf2c38ede4616dd950bdbae8ad096ced8a Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:47:19 +0900 Subject: [PATCH 5/6] Shorten changesets --- .changes/degradation-preference-disabled-mapping | 2 +- .changes/enum-conversion-fallbacks | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changes/degradation-preference-disabled-mapping b/.changes/degradation-preference-disabled-mapping index 7ca6a803b..16e74b29d 100644 --- a/.changes/degradation-preference-disabled-mapping +++ b/.changes/degradation-preference-disabled-mapping @@ -1 +1 @@ -patch type="fixed" "The deprecated DegradationPreference.disabled is now handled as maintainFramerateAndResolution, matching how WebRTC defines it" +patch type="fixed" "DegradationPreference.disabled now maps to maintainFramerateAndResolution, as WebRTC defines it" diff --git a/.changes/enum-conversion-fallbacks b/.changes/enum-conversion-fallbacks index 38657cc4c..07c236fe6 100644 --- a/.changes/enum-conversion-fallbacks +++ b/.changes/enum-conversion-fallbacks @@ -1 +1 @@ -patch type="changed" "Enum conversions are more robust, unrecognized protobuf values from newer servers now fall back to safe defaults (for example a new DisconnectReason maps to unknown)" +patch type="changed" "Unrecognized protobuf enum values from newer servers now fall back to safe defaults, for example a new DisconnectReason maps to unknown" From f60990ac8d779658f5a0636c034049859be5f7f4 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:12:24 +0900 Subject: [PATCH 6/6] Remove unused toRid helper and pin proto enum conversions with tests toRid had no callers and mapped VideoQuality.OFF to the low quality rid, which would be wrong if it ever gained one. The new test pins the value count of every converted protobuf enum, so a proto regen that adds a value fails the test and points at the conversion, since the wildcard arms mean the analyzer can no longer flag it. --- lib/src/extensions.dart | 7 -- test/types/proto_enum_conversion_test.dart | 109 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 test/types/proto_enum_conversion_test.dart diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 171dfe799..a2886aedd 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -156,13 +156,6 @@ extension VideoQualityExt on lk_models.VideoQuality { lk_models.VideoQuality.LOW => VideoQuality.LOW, _ => VideoQuality.LOW, }; - - String toRid() => switch (this) { - lk_models.VideoQuality.HIGH => 'f', - lk_models.VideoQuality.MEDIUM => 'h', - lk_models.VideoQuality.LOW => 'q', - _ => 'q', - }; } extension PBVideoQualityExt on VideoQuality { diff --git a/test/types/proto_enum_conversion_test.dart b/test/types/proto_enum_conversion_test.dart new file mode 100644 index 000000000..320cf0838 --- /dev/null +++ b/test/types/proto_enum_conversion_test.dart @@ -0,0 +1,109 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/e2ee/options.dart'; +import 'package:livekit_client/src/extensions.dart'; +import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; +import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_rtc; +import 'package:livekit_client/src/types/other.dart'; + +// The switches converting protobuf enums in extensions.dart cannot be compile +// time exhaustive, protobuf enums are classes rather than Dart enums, so new +// proto values silently take the wildcard arm. These tests pin the value count +// of each converted proto enum. When a count assertion fails after a proto +// regen, decide how the new value should convert in extensions.dart, then +// update the count and expectations here. +const countHint = 'proto enum gained a value, update the conversion in extensions.dart'; + +void main() { + group('protobuf enum conversions cover every proto value', () { + test('DataPacket_Kind', () { + expect(lk_models.DataPacket_Kind.values, hasLength(2), reason: countHint); + expect(lk_models.DataPacket_Kind.RELIABLE.toSDKType(), Reliability.reliable); + expect(lk_models.DataPacket_Kind.LOSSY.toSDKType(), Reliability.lossy); + }); + + test('ConnectionQuality', () { + expect(lk_models.ConnectionQuality.values, hasLength(4), reason: countHint); + expect(lk_models.ConnectionQuality.LOST.toLKType(), ConnectionQuality.lost); + expect(lk_models.ConnectionQuality.POOR.toLKType(), ConnectionQuality.poor); + expect(lk_models.ConnectionQuality.GOOD.toLKType(), ConnectionQuality.good); + expect(lk_models.ConnectionQuality.EXCELLENT.toLKType(), ConnectionQuality.excellent); + }); + + test('VideoQuality', () { + expect(lk_models.VideoQuality.values, hasLength(4), reason: countHint); + expect(lk_models.VideoQuality.LOW.toLKType(), VideoQuality.LOW); + expect(lk_models.VideoQuality.MEDIUM.toLKType(), VideoQuality.MEDIUM); + expect(lk_models.VideoQuality.HIGH.toLKType(), VideoQuality.HIGH); + // the SDK enum has no OFF member, collapsing to LOW is intentional + expect(lk_models.VideoQuality.OFF.toLKType(), VideoQuality.LOW); + }); + + test('TrackType', () { + expect(lk_models.TrackType.values, hasLength(3), reason: countHint); + expect(lk_models.TrackType.AUDIO.toLKType(), TrackType.AUDIO); + expect(lk_models.TrackType.VIDEO.toLKType(), TrackType.VIDEO); + expect(lk_models.TrackType.DATA.toLKType(), TrackType.DATA); + }); + + test('TrackSource', () { + expect(lk_models.TrackSource.values, hasLength(5), reason: countHint); + expect(lk_models.TrackSource.UNKNOWN.toLKType(), TrackSource.unknown); + expect(lk_models.TrackSource.CAMERA.toLKType(), TrackSource.camera); + expect(lk_models.TrackSource.MICROPHONE.toLKType(), TrackSource.microphone); + expect(lk_models.TrackSource.SCREEN_SHARE.toLKType(), TrackSource.screenShareVideo); + expect(lk_models.TrackSource.SCREEN_SHARE_AUDIO.toLKType(), TrackSource.screenShareAudio); + }); + + test('StreamState', () { + expect(lk_rtc.StreamState.values, hasLength(2), reason: countHint); + expect(lk_rtc.StreamState.ACTIVE.toLKType(), StreamState.active); + expect(lk_rtc.StreamState.PAUSED.toLKType(), StreamState.paused); + }); + + test('Encryption_Type', () { + expect(lk_models.Encryption_Type.values, hasLength(3), reason: countHint); + expect(lk_models.Encryption_Type.NONE.toLkType(), EncryptionType.kNone); + expect(lk_models.Encryption_Type.GCM.toLkType(), EncryptionType.kGcm); + expect(lk_models.Encryption_Type.CUSTOM.toLkType(), EncryptionType.kCustom); + }); + + test('ParticipantInfo_Kind', () { + expect(lk_models.ParticipantInfo_Kind.values, hasLength(7), reason: countHint); + expect(lk_models.ParticipantInfo_Kind.STANDARD.toLKType(), ParticipantKind.STANDARD); + expect(lk_models.ParticipantInfo_Kind.INGRESS.toLKType(), ParticipantKind.INGRESS); + expect(lk_models.ParticipantInfo_Kind.EGRESS.toLKType(), ParticipantKind.EGRESS); + expect(lk_models.ParticipantInfo_Kind.SIP.toLKType(), ParticipantKind.SIP); + expect(lk_models.ParticipantInfo_Kind.AGENT.toLKType(), ParticipantKind.AGENT); + // the SDK enum has no members for these yet, they collapse to STANDARD + expect(lk_models.ParticipantInfo_Kind.CONNECTOR.toLKType(), ParticipantKind.STANDARD); + expect(lk_models.ParticipantInfo_Kind.BRIDGE.toLKType(), ParticipantKind.STANDARD); + }); + + test('DisconnectReason', () { + expect(lk_models.DisconnectReason.values, hasLength(17), reason: countHint); + expect(lk_models.DisconnectReason.UNKNOWN_REASON.toSDKType(), DisconnectReason.unknown); + expect(lk_models.DisconnectReason.CLIENT_INITIATED.toSDKType(), DisconnectReason.clientInitiated); + expect(lk_models.DisconnectReason.DUPLICATE_IDENTITY.toSDKType(), DisconnectReason.duplicateIdentity); + expect(lk_models.DisconnectReason.SERVER_SHUTDOWN.toSDKType(), DisconnectReason.serverShutdown); + expect(lk_models.DisconnectReason.PARTICIPANT_REMOVED.toSDKType(), DisconnectReason.participantRemoved); + expect(lk_models.DisconnectReason.ROOM_DELETED.toSDKType(), DisconnectReason.roomDeleted); + expect(lk_models.DisconnectReason.STATE_MISMATCH.toSDKType(), DisconnectReason.stateMismatch); + expect(lk_models.DisconnectReason.JOIN_FAILURE.toSDKType(), DisconnectReason.joinFailure); + }); + }); +}