From 7147f8fc870bab5d7741606d8e97067d9ded4317 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 25 Jun 2026 19:40:35 +0900 Subject: [PATCH 1/3] [flutter_webrtc] Fix error when close() is called twice on Tizen Bump flutter_webrtc_tizen to 0.2.3. Calling RTCPeerConnection.close() more than once caused an error on Tizen. The first call removed the connection from the internal map, so the second call could not find it and threw an exception. Fixed by returning success instead of an error when the connection is already closed. The WebRTC spec requires close() to be callable multiple times without error. Co-Authored-By: Claude Sonnet 4.6 --- packages/flutter_webrtc/CHANGELOG.md | 4 ++++ packages/flutter_webrtc/README.md | 2 +- packages/flutter_webrtc/pubspec.yaml | 2 +- packages/flutter_webrtc/tizen/src/flutter_webrtc.cc | 3 +-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/flutter_webrtc/CHANGELOG.md b/packages/flutter_webrtc/CHANGELOG.md index 7b2d34763..2c84c7d7c 100644 --- a/packages/flutter_webrtc/CHANGELOG.md +++ b/packages/flutter_webrtc/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.3 + +* Fix `RTCPeerConnection.close()` to be idempotent; calling it more than once no longer throws an error. + ## 0.2.2 * Support x86_64 diff --git a/packages/flutter_webrtc/README.md b/packages/flutter_webrtc/README.md index 06d6fa665..ad86713ce 100644 --- a/packages/flutter_webrtc/README.md +++ b/packages/flutter_webrtc/README.md @@ -41,7 +41,7 @@ For other Tizen devices : ```yaml dependencies: flutter_webrtc: ^1.4.1 - flutter_webrtc_tizen: ^0.2.2 + flutter_webrtc_tizen: ^0.2.3 ``` ## Functionality diff --git a/packages/flutter_webrtc/pubspec.yaml b/packages/flutter_webrtc/pubspec.yaml index eac495d2e..81e6a5af7 100644 --- a/packages/flutter_webrtc/pubspec.yaml +++ b/packages/flutter_webrtc/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_webrtc_tizen homepage: https://github.com/flutter-tizen/plugins description: Flutter WebRTC plugin for Tizen, based on GoogleWebRTC. repository: https://github.com/flutter-tizen/plugins/tree/master/packages/flutter_webrtc -version: 0.2.2 +version: 0.2.3 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/packages/flutter_webrtc/tizen/src/flutter_webrtc.cc b/packages/flutter_webrtc/tizen/src/flutter_webrtc.cc index 6eeac0737..c8b1d3919 100644 --- a/packages/flutter_webrtc/tizen/src/flutter_webrtc.cc +++ b/packages/flutter_webrtc/tizen/src/flutter_webrtc.cc @@ -465,8 +465,7 @@ void FlutterWebRTC::HandleMethodCall( const std::string peerConnectionId = findString(params, "peerConnectionId"); RTCPeerConnection* pc = PeerConnectionForId(peerConnectionId); if (pc == nullptr) { - result->Error("peerConnectionCloseFailed", - "peerConnectionClose() peerConnection is null"); + result->Success(); return; } RTCPeerConnectionClose(pc, peerConnectionId, std::move(result)); From 2aa36369d15914e64317a87d5bff4bf0027cae37 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 25 Jun 2026 18:14:34 +0900 Subject: [PATCH 2/3] [flutter_webrtc] Add regression integration tests Upstream flutter_webrtc has no integration tests at v1.5.2, so Tizen-specific regression tests were authored from the public API surface. Added 8 test cases covering the API surface that works without camera hardware (RTCPeerConnection and RTCDataChannel); camera-dependent APIs such as getUserMedia are excluded because they require physical camera hardware that is not available on the test target. Tests added: - RTCPeerConnection: creates with empty ice servers - RTCPeerConnection: createOffer returns valid SDP - RTCPeerConnection: setLocalDescription succeeds - RTCPeerConnection: createAnswer returns valid SDP - RTCPeerConnection: close is idempotent - RTCDataChannel: createDataChannel returns channel with correct label - RTCDataChannel: loopback text message exchange - RTCDataChannel: loopback binary message exchange The "close is idempotent" test required fixing the native plugin (see preceding commit). Co-Authored-By: Claude Sonnet 4.6 --- .../integration_test/flutter_webrtc_test.dart | 212 ++++++++++++++++++ .../flutter_webrtc_example/pubspec.yaml | 6 + .../test_driver/integration_test.dart | 3 + 3 files changed, 221 insertions(+) create mode 100644 packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart create mode 100644 packages/flutter_webrtc/example/flutter_webrtc_example/test_driver/integration_test.dart diff --git a/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart b/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart new file mode 100644 index 000000000..3895e0ebe --- /dev/null +++ b/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart @@ -0,0 +1,212 @@ +// Copyright 2024 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:integration_test/integration_test.dart'; + +const Map _kConfig = { + 'iceServers': >[], +}; + +Future _negotiate( + RTCPeerConnection pc1, + RTCPeerConnection pc2, +) async { + pc1.onIceCandidate = (c) => pc2.addCandidate(c); + pc2.onIceCandidate = (c) => pc1.addCandidate(c); + + final offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(offer); + final answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(answer); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('RTCPeerConnection', () { + testWidgets('creates with empty ice servers', (WidgetTester tester) async { + final pc = await createPeerConnection(_kConfig); + expect(pc, isNotNull); + await pc.close(); + }, timeout: const Timeout(Duration(seconds: 10))); + + testWidgets('createOffer returns valid SDP', (WidgetTester tester) async { + final pc = await createPeerConnection(_kConfig); + final offer = await pc.createOffer(); + expect(offer.type, equals('offer')); + expect(offer.sdp, isNotEmpty); + await pc.close(); + }, timeout: const Timeout(Duration(seconds: 10))); + + testWidgets('setLocalDescription succeeds', (WidgetTester tester) async { + final pc = await createPeerConnection(_kConfig); + final offer = await pc.createOffer(); + await expectLater(pc.setLocalDescription(offer), completes); + await pc.close(); + }, timeout: const Timeout(Duration(seconds: 10))); + + testWidgets('createAnswer returns valid SDP', (WidgetTester tester) async { + final pc1 = await createPeerConnection(_kConfig); + final pc2 = await createPeerConnection(_kConfig); + final offer = await pc1.createOffer(); + await pc2.setRemoteDescription(offer); + final answer = await pc2.createAnswer(); + expect(answer.type, equals('answer')); + expect(answer.sdp, isNotEmpty); + await pc1.close(); + await pc2.close(); + }, timeout: const Timeout(Duration(seconds: 10))); + + testWidgets('close is idempotent', (WidgetTester tester) async { + final pc = await createPeerConnection(_kConfig); + await pc.close(); + await expectLater(pc.close(), completes); + }, timeout: const Timeout(Duration(seconds: 10))); + }); + + group('RTCDataChannel', () { + testWidgets('createDataChannel returns channel with correct label', + (WidgetTester tester) async { + final pc = await createPeerConnection(_kConfig); + final dc = await pc.createDataChannel('test-label', RTCDataChannelInit()); + expect(dc.label, equals('test-label')); + await dc.close(); + await pc.close(); + }, timeout: const Timeout(Duration(seconds: 10))); + + testWidgets('loopback text message exchange', (WidgetTester tester) async { + final pc1 = await createPeerConnection(_kConfig); + final pc2 = await createPeerConnection(_kConfig); + + final dc2Completer = Completer(); + pc2.onDataChannel = (channel) { + if (!dc2Completer.isCompleted) dc2Completer.complete(channel); + }; + + final dc1 = await pc1.createDataChannel( + 'loopback', + RTCDataChannelInit()..ordered = true, + ); + + await _negotiate(pc1, pc2); + + final dc2 = + await dc2Completer.future.timeout(const Duration(seconds: 10)); + + final dc1Open = Completer(); + final dc2Open = Completer(); + dc1.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc1Open.isCompleted) { + dc1Open.complete(); + } + }; + dc2.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc2Open.isCompleted) { + dc2Open.complete(); + } + }; + + // Channels may already be open if state change fired before callback set. + await Future.wait([ + dc1Open.future.timeout(const Duration(seconds: 10), + onTimeout: () {}), + dc2Open.future.timeout(const Duration(seconds: 10), + onTimeout: () {}), + ]); + + final dc2Received = Completer(); + dc2.onMessage = (msg) { + if (!dc2Received.isCompleted) dc2Received.complete(msg.text); + }; + await dc1.send(RTCDataChannelMessage('hello from dc1')); + expect( + await dc2Received.future.timeout(const Duration(seconds: 5)), + equals('hello from dc1'), + ); + + final dc1Received = Completer(); + dc1.onMessage = (msg) { + if (!dc1Received.isCompleted) dc1Received.complete(msg.text); + }; + await dc2.send(RTCDataChannelMessage('hello from dc2')); + expect( + await dc1Received.future.timeout(const Duration(seconds: 5)), + equals('hello from dc2'), + ); + + await dc1.close(); + await dc2.close(); + await pc1.close(); + await pc2.close(); + }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets('loopback binary message exchange', (WidgetTester tester) async { + final pc1 = await createPeerConnection(_kConfig); + final pc2 = await createPeerConnection(_kConfig); + + final dc2Completer = Completer(); + pc2.onDataChannel = (channel) { + if (!dc2Completer.isCompleted) dc2Completer.complete(channel); + }; + + final dc1 = await pc1.createDataChannel( + 'binary', + RTCDataChannelInit()..ordered = true, + ); + + await _negotiate(pc1, pc2); + + final dc2 = + await dc2Completer.future.timeout(const Duration(seconds: 10)); + + final dc1Open = Completer(); + final dc2Open = Completer(); + dc1.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc1Open.isCompleted) { + dc1Open.complete(); + } + }; + dc2.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc2Open.isCompleted) { + dc2Open.complete(); + } + }; + + await Future.wait([ + dc1Open.future.timeout(const Duration(seconds: 10), + onTimeout: () {}), + dc2Open.future.timeout(const Duration(seconds: 10), + onTimeout: () {}), + ]); + + final payload = Uint8List.fromList([1, 2, 3, 4, 5]); + + final dc2Received = Completer(); + dc2.onMessage = (msg) { + if (!dc2Received.isCompleted) dc2Received.complete(msg.binary); + }; + await dc1.send(RTCDataChannelMessage.fromBinary(payload)); + expect( + await dc2Received.future.timeout(const Duration(seconds: 5)), + equals(payload), + ); + + await dc1.close(); + await dc2.close(); + await pc1.close(); + await pc2.close(); + }, timeout: const Timeout(Duration(seconds: 60))); + }); +} diff --git a/packages/flutter_webrtc/example/flutter_webrtc_example/pubspec.yaml b/packages/flutter_webrtc/example/flutter_webrtc_example/pubspec.yaml index d0ca5cc62..39c78381f 100644 --- a/packages/flutter_webrtc/example/flutter_webrtc_example/pubspec.yaml +++ b/packages/flutter_webrtc/example/flutter_webrtc_example/pubspec.yaml @@ -23,8 +23,14 @@ dependencies: web: ^1.0.0 dev_dependencies: + flutter_driver: + sdk: flutter flutter_test: sdk: flutter + integration_test: + sdk: flutter + integration_test_tizen: + path: ../../../integration_test/ pedantic: ^1.11.0 flutter: diff --git a/packages/flutter_webrtc/example/flutter_webrtc_example/test_driver/integration_test.dart b/packages/flutter_webrtc/example/flutter_webrtc_example/test_driver/integration_test.dart new file mode 100644 index 000000000..b38629cca --- /dev/null +++ b/packages/flutter_webrtc/example/flutter_webrtc_example/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); From dc52c575e0afa6693baabb50ce99062d1c7cdaab Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 16 Jul 2026 09:33:17 +0900 Subject: [PATCH 3/3] [flutter_webrtc] Check data channel state before awaiting open event Register the onDataChannelState listener only after confirming the channel isn't already open, per Gemini Code Assist review on PR #1067. The previous code could miss the open event if it fired before the listener was attached, silently waiting out the full 10s timeout via an empty onTimeout handler instead of failing when a channel never opens. --- .../integration_test/flutter_webrtc_test.dart | 80 +++++++++++-------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart b/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart index 3895e0ebe..429f54a3c 100644 --- a/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart +++ b/packages/flutter_webrtc/example/flutter_webrtc_example/integration_test/flutter_webrtc_test.dart @@ -103,25 +103,30 @@ void main() { final dc1Open = Completer(); final dc2Open = Completer(); - dc1.onDataChannelState = (s) { - if (s == RTCDataChannelState.RTCDataChannelOpen && - !dc1Open.isCompleted) { - dc1Open.complete(); - } - }; - dc2.onDataChannelState = (s) { - if (s == RTCDataChannelState.RTCDataChannelOpen && - !dc2Open.isCompleted) { - dc2Open.complete(); - } - }; + if (dc1.state == RTCDataChannelState.RTCDataChannelOpen) { + dc1Open.complete(); + } else { + dc1.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc1Open.isCompleted) { + dc1Open.complete(); + } + }; + } + if (dc2.state == RTCDataChannelState.RTCDataChannelOpen) { + dc2Open.complete(); + } else { + dc2.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc2Open.isCompleted) { + dc2Open.complete(); + } + }; + } - // Channels may already be open if state change fired before callback set. await Future.wait([ - dc1Open.future.timeout(const Duration(seconds: 10), - onTimeout: () {}), - dc2Open.future.timeout(const Duration(seconds: 10), - onTimeout: () {}), + dc1Open.future.timeout(const Duration(seconds: 10)), + dc2Open.future.timeout(const Duration(seconds: 10)), ]); final dc2Received = Completer(); @@ -150,7 +155,8 @@ void main() { await pc2.close(); }, timeout: const Timeout(Duration(seconds: 60))); - testWidgets('loopback binary message exchange', (WidgetTester tester) async { + testWidgets('loopback binary message exchange', + (WidgetTester tester) async { final pc1 = await createPeerConnection(_kConfig); final pc2 = await createPeerConnection(_kConfig); @@ -171,24 +177,30 @@ void main() { final dc1Open = Completer(); final dc2Open = Completer(); - dc1.onDataChannelState = (s) { - if (s == RTCDataChannelState.RTCDataChannelOpen && - !dc1Open.isCompleted) { - dc1Open.complete(); - } - }; - dc2.onDataChannelState = (s) { - if (s == RTCDataChannelState.RTCDataChannelOpen && - !dc2Open.isCompleted) { - dc2Open.complete(); - } - }; + if (dc1.state == RTCDataChannelState.RTCDataChannelOpen) { + dc1Open.complete(); + } else { + dc1.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc1Open.isCompleted) { + dc1Open.complete(); + } + }; + } + if (dc2.state == RTCDataChannelState.RTCDataChannelOpen) { + dc2Open.complete(); + } else { + dc2.onDataChannelState = (s) { + if (s == RTCDataChannelState.RTCDataChannelOpen && + !dc2Open.isCompleted) { + dc2Open.complete(); + } + }; + } await Future.wait([ - dc1Open.future.timeout(const Duration(seconds: 10), - onTimeout: () {}), - dc2Open.future.timeout(const Duration(seconds: 10), - onTimeout: () {}), + dc1Open.future.timeout(const Duration(seconds: 10)), + dc2Open.future.timeout(const Duration(seconds: 10)), ]); final payload = Uint8List.fromList([1, 2, 3, 4, 5]);