Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/stream_video_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Upcoming

### 🐞 Fixed
* [iOS] Fixed CallKit event suppression to avoid repeated mute toggle loops.

## 1.2.2

### 🐞 Fixed
Expand Down
5 changes: 5 additions & 0 deletions packages/stream_video_push_notification/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Upcoming

🐞 Fixed
* [iOS] Fixed CallKit event suppression to avoid repeated mute toggle loops.

## 1.2.2
* Sync version with `stream_video_flutter` 1.2.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,10 @@ class StreamVideoPushNotificationManager implements PushNotificationManager {
() =>
'[subscribeToEvents] Call accepted on the same device, ending CallKit silently: ${event.callCid}',
);
await StreamVideoPushNotificationPlatform.instance.silenceEvents();

// Call was already accepted, end the CallKit call silently
RingingEventBroadcaster().suppressEvent();
await endCallByCid(event.callCid.toString());
await Future<void>.delayed(const Duration(milliseconds: 300));
await StreamVideoPushNotificationPlatform.instance
.unsilenceEvents();
}
}),
);
Expand Down Expand Up @@ -453,13 +452,16 @@ class StreamVideoPushNotificationManager implements PushNotificationManager {
.toList();

for (final call in calls) {
// Silence events to avoid infinite loop
await StreamVideoPushNotificationPlatform.instance.silenceEvents();
// Suppress CallKit event to avoid loop
RingingEventBroadcaster().suppressEvent(
eventType: ActionCallToggleMute,
valueKey: isMuted.toString(),
);

await StreamVideoPushNotificationPlatform.instance.muteCall(
call.uuid!,
isMuted: isMuted,
);
await StreamVideoPushNotificationPlatform.instance.unsilenceEvents();
}
}

Expand Down Expand Up @@ -549,6 +551,24 @@ CallData _callDataFromJson(Map<String, dynamic> json) {
);
}

class _RingingEventSuppression {
const _RingingEventSuppression({
required this.eventType,
required this.valueKey,
required this.timestamp,
required this.window,
});

final Type? eventType;
final String? valueKey;
final DateTime timestamp;
final Duration window;

bool isValid() {
return DateTime.now().difference(timestamp) <= window;
}
}

/// Wrapper class to support multiple subscriptions to the
/// [StreamVideoPushNotificationPlatform.onEvent] stream.
final class RingingEventBroadcaster {
Expand All @@ -557,9 +577,11 @@ final class RingingEventBroadcaster {

RingingEventBroadcaster._();

static const _ringingEventSuppressionWindow = Duration(milliseconds: 500);
static RingingEventBroadcaster? _singleton;

StreamController<RingingEvent>? _controller;
final List<_RingingEventSuppression> _suppressions = [];

/// Returns a Stream of [RingingEvent].
Stream<RingingEvent> get onEvent {
Expand All @@ -570,18 +592,62 @@ final class RingingEventBroadcaster {
return _controller!.stream;
}

/// Suppresses upcoming Ringing events.
///
/// - If [eventType] is null, all events are suppressed.
/// - If [valueKey] is provided, it must match the event value to suppress.
void suppressEvent({
Type? eventType,
String? valueKey,
Duration window = _ringingEventSuppressionWindow,
}) {
_suppressions.add(
_RingingEventSuppression(
eventType: eventType,
valueKey: valueKey,
timestamp: DateTime.now(),
window: window,
),
);
}

StreamSubscription<RingingEvent?>? _eventSubscription;

Future<void> _startListenEvent() async {
_eventSubscription ??= StreamVideoPushNotificationPlatform.instance.onEvent
.distinct()
.listen((event) {
if (event != null) _controller?.add(event);
if (event == null) return;
if (_shouldSuppressEvent(event)) return;
_controller?.add(event);
});
}

Future<void> _stopListenEvent() async {
await _eventSubscription?.cancel();
_eventSubscription = null;
}

bool _shouldSuppressEvent(RingingEvent event) {
_suppressions.removeWhere((suppression) => !suppression.isValid());
if (_suppressions.isEmpty) {
return false;
}

final valueKey = _eventValueKey(event);
return _suppressions.any(
(suppression) =>
suppression.eventType == null ||
(event.runtimeType == suppression.eventType &&
suppression.valueKey == valueKey),
);
}

String? _eventValueKey(RingingEvent event) {
return switch (event) {
ActionCallToggleMute(:final isMuted) => isMuted.toString(),
ActionCallToggleHold(:final isOnHold) => isOnHold.toString(),
_ => null,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:stream_video/stream_video.dart';
import 'package:stream_video_push_notification/src/stream_video_push_notification.dart';
import 'package:stream_video_push_notification/stream_video_push_notification_platform_interface.dart';

class _FakePushNotificationPlatform
extends StreamVideoPushNotificationPlatform {
final StreamController<RingingEvent?> _controller =
StreamController<RingingEvent?>.broadcast();

@override
Stream<RingingEvent?> get onEvent => _controller.stream;

void emit(RingingEvent event) => _controller.add(event);

Future<void> dispose() => _controller.close();
}

CallData _callData() => const CallData(uuid: 'uuid-1', callCid: 'default:1');

void main() {
late _FakePushNotificationPlatform platform;
late StreamSubscription<RingingEvent> subscription;
late List<RingingEvent> events;

setUpAll(() {
platform = _FakePushNotificationPlatform();
StreamVideoPushNotificationPlatform.instance = platform;
});

setUp(() {
events = <RingingEvent>[];
subscription = RingingEventBroadcaster().onEvent.listen(events.add);
});

tearDown(() async {
await Future<void>.delayed(const Duration(milliseconds: 60));
platform.emit(ActionCallIncoming(data: _callData()));
await Future<void>.delayed(const Duration(milliseconds: 5));
await subscription.cancel();
});

test('suppresses all events within window', () async {
RingingEventBroadcaster().suppressEvent(
window: const Duration(
milliseconds: 50,
),
);

platform.emit(ActionCallIncoming(data: _callData()));
await Future<void>.delayed(const Duration(milliseconds: 10));

expect(events, isEmpty);
});

test('suppresses a specific event type and value', () async {
RingingEventBroadcaster().suppressEvent(
eventType: ActionCallToggleMute,
valueKey: 'true',
window: const Duration(milliseconds: 50),
);

platform.emit(const ActionCallToggleMute(uuid: 'uuid-1', isMuted: true));
platform.emit(const ActionCallToggleMute(uuid: 'uuid-1', isMuted: false));
await Future<void>.delayed(const Duration(milliseconds: 10));

expect(events.length, 1);
expect(
events.first,
const ActionCallToggleMute(uuid: 'uuid-1', isMuted: false),
);
});

test('suppression expires after window', () async {
RingingEventBroadcaster().suppressEvent(
eventType: ActionCallToggleMute,
valueKey: 'true',
window: const Duration(milliseconds: 20),
);

await Future<void>.delayed(const Duration(milliseconds: 30));
platform.emit(const ActionCallToggleMute(uuid: 'uuid-1', isMuted: true));
await Future<void>.delayed(const Duration(milliseconds: 10));

expect(events.length, 1);
expect(
events.first,
const ActionCallToggleMute(uuid: 'uuid-1', isMuted: true),
);
});

test('suppression applies to multiple matching events in window', () async {
RingingEventBroadcaster().suppressEvent(
eventType: ActionCallToggleMute,
valueKey: 'true',
window: const Duration(milliseconds: 50),
);

platform.emit(const ActionCallToggleMute(uuid: 'uuid-1', isMuted: true));
platform.emit(const ActionCallToggleMute(uuid: 'uuid-1', isMuted: true));
await Future<void>.delayed(const Duration(milliseconds: 10));

expect(events, isEmpty);
});
}
Loading