From aef1fd8804b9b76afa4468229f57b8f43b180c96 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 10 Jan 2022 23:48:44 +0700 Subject: [PATCH] Subscription permission API (#68) * signal * request * implement * rename event * ref * adjustments based on review * override subscribed * move events emit * fix subscription state * minor change * update example for permissions demo * format & subscriptionState as computed property * prepare macos build * unsubscribed event when subscription not allowed --- example/lib/exts.dart | 19 +++++++ example/lib/widgets/controls.dart | 18 +++++++ example/lib/widgets/participant.dart | 40 +++++++------- lib/src/core/engine.dart | 2 + lib/src/core/room.dart | 19 +++++++ lib/src/core/signal_client.dart | 18 +++++++ lib/src/events.dart | 15 ++++++ lib/src/extensions.dart | 8 +++ lib/src/internal/events.dart | 13 +++++ lib/src/participant/local.dart | 27 ++++++++++ lib/src/publication/remote.dart | 79 +++++++++++++++++++++++++--- lib/src/types.dart | 25 +++++++++ 12 files changed, 259 insertions(+), 24 deletions(-) diff --git a/example/lib/exts.dart b/example/lib/exts.dart index 15f4ab5..309aca0 100644 --- a/example/lib/exts.dart +++ b/example/lib/exts.dart @@ -135,4 +135,23 @@ extension LKExampleExt on BuildContext { ], ), ); + + Future showSubscribePermissionDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Allow subscription'), + content: const Text( + 'Allow all participants to subscribe tracks published by local participant?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('NO'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('YES'), + ), + ], + ), + ); } diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index f7c7064..68b9fec 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -136,6 +136,19 @@ class _ControlsWidgetState extends State { } } + void _onTapUpdateSubscribePermission() async { + final result = await context.showSubscribePermissionDialog(); + if (result != null) { + try { + widget.room.localParticipant?.setTrackSubscriptionPermissions( + allParticipantsAllowed: result, + ); + } catch (error) { + await context.showErrorDialog(error); + } + } + } + void _onTapSendData() async { final result = await context.showSendDataDialog(); if (result == true) { @@ -220,6 +233,11 @@ class _ControlsWidgetState extends State { icon: const Icon(EvaIcons.refresh), tooltip: 're-connect', ), + IconButton( + onPressed: _onTapUpdateSubscribePermission, + icon: const Icon(EvaIcons.settings2), + tooltip: 'Subscribe permission', + ), ], ), ); diff --git a/example/lib/widgets/participant.dart b/example/lib/widgets/participant.dart index 73cdf5c..f43f974 100644 --- a/example/lib/widgets/participant.dart +++ b/example/lib/widgets/participant.dart @@ -192,13 +192,13 @@ class _RemoteParticipantWidgetState if (firstVideoPublication != null) RemoteTrackPublicationMenuWidget( pub: firstVideoPublication!, - icon: EvaIcons.videoOutline, + icon: EvaIcons.video, ), // Menu for RemoteTrackPublication if (firstAudioPublication != null) RemoteTrackPublicationMenuWidget( pub: firstAudioPublication!, - icon: EvaIcons.volumeUpOutline, + icon: EvaIcons.volumeUp, ), ], ), @@ -218,23 +218,27 @@ class RemoteTrackPublicationMenuWidget extends StatelessWidget { Widget build(BuildContext context) => Material( color: Colors.black.withOpacity(0.3), child: PopupMenuButton( - icon: Icon(icon), + tooltip: 'Subscribe menu', + icon: Icon(icon, + color: { + TrackSubscriptionState.notAllowed: Colors.red, + TrackSubscriptionState.unsubscribed: Colors.grey, + TrackSubscriptionState.subscribed: Colors.green, + }[pub.subscriptionState]), onSelected: (value) => value(), - itemBuilder: (BuildContext context) { - return >[ - // Subscribe/Unsubscribe - if (pub.subscribed == false) - PopupMenuItem( - child: const Text('Subscribe'), - value: () => pub.subscribed = true, - ) - else if (pub.subscribed == true) - PopupMenuItem( - child: const Text('Un-subscribe'), - value: () => pub.subscribed = false, - ), - ]; - }, + itemBuilder: (BuildContext context) => >[ + // Subscribe/Unsubscribe + if (pub.subscribed == false) + PopupMenuItem( + child: const Text('Subscribe'), + value: () => pub.subscribe(), + ) + else if (pub.subscribed == true) + PopupMenuItem( + child: const Text('Un-subscribe'), + value: () => pub.unsubscribe(), + ), + ], ), ); } diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index b536af9..9f2fb9b 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -621,6 +621,8 @@ class Engine extends Disposable with EventsEmittable { ..on((event) => events.emit(event)) // relay to Room ..on((event) => events.emit(event)) + // relay to Room + ..on((event) => events.emit(event)) ..on((event) async { if (connectionState == ConnectionState.reconnecting) { logger.warning('Received leave signal while engine is reconnecting.'); diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 17168fe..000d51c 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -191,6 +191,25 @@ class Room extends DisposableChangeNotifier with EventsEmittable { } publication.updatePublishingLayers(event.updates); }) + ..on((event) async { + logger.fine('SignalSubscriptionPermissionUpdateEvent ' + 'participantSid:${event.participantSid} ' + 'trackSid:${event.trackSid} ' + 'allowed:${event.allowed}'); + + // find participant + final participant = _participants[event.participantSid]; + if (participant == null) { + return; + } + // find track + final publication = participant.trackPublications[event.trackSid]; + if (publication == null) { + return; + } + // + await publication.updateSubscriptionAllowed(event.allowed); + }) ..on((event) async { logger.fine('EngineTrackAddedEvent trackSid:${event.track.id}'); diff --git a/lib/src/core/signal_client.dart b/lib/src/core/signal_client.dart index ddb7758..68ae7f1 100644 --- a/lib/src/core/signal_client.dart +++ b/lib/src/core/signal_client.dart @@ -186,6 +186,13 @@ class SignalClient extends Disposable with EventsEmittable { updates: msg.subscribedQualityUpdate.subscribedQualities, )); break; + case lk_rtc.SignalResponse_Message.subscriptionPermissionUpdate: + events.emit(SignalSubscriptionPermissionUpdateEvent( + participantSid: msg.subscriptionPermissionUpdate.participantSid, + trackSid: msg.subscriptionPermissionUpdate.trackSid, + allowed: msg.subscriptionPermissionUpdate.allowed, + )); + break; default: logger.warning('skipping unsupported signal message'); } @@ -296,6 +303,17 @@ extension SignalClientRequests on SignalClient { ), )); + void sendUpdateSubscriptionPermissions({ + required bool allParticipants, + required List trackPermissions, + }) => + _sendRequest(lk_rtc.SignalRequest( + subscriptionPermissions: lk_rtc.UpdateSubscriptionPermissions( + allParticipants: allParticipants, + trackPermissions: trackPermissions, + ), + )); + void sendLeave() => _sendRequest(lk_rtc.SignalRequest( leave: lk_rtc.LeaveRequest(), )); diff --git a/lib/src/events.dart b/lib/src/events.dart index f7c91a8..9a12c56 100644 --- a/lib/src/events.dart +++ b/lib/src/events.dart @@ -244,3 +244,18 @@ class SpeakingChangedEvent with ParticipantEvent { required this.speaking, }); } + +/// One of subscribed tracks have changed its permissions for the current +/// participant. If permission was revoked, then the track will no longer +/// be subscribed. If permission was granted, a TrackSubscribed event will +/// be emitted. +class TrackSubscriptionPermissionChangedEvent with RoomEvent, ParticipantEvent { + final Participant participant; + final RemoteTrackPublication trackPublication; + final TrackSubscriptionState state; + const TrackSubscriptionPermissionChangedEvent({ + required this.participant, + required this.trackPublication, + required this.state, + }); +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 978db48..d09b0ff 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -140,3 +140,11 @@ extension VideoQualityExt on lk_models.VideoQuality { lk_models.VideoQuality.LOW: 'q', }[this]!; } + +extension ParticipantTrackPermissionExt on ParticipantTrackPermission { + lk_rtc.TrackPermission toPBType() => lk_rtc.TrackPermission( + participantSid: participantSid, + allTracks: allTracksAllowed, + trackSids: allowedTrackSids, + ); +} diff --git a/lib/src/internal/events.dart b/lib/src/internal/events.dart index fcd61d1..81e7ae6 100644 --- a/lib/src/internal/events.dart +++ b/lib/src/internal/events.dart @@ -207,6 +207,19 @@ class SignalSubscribedQualityUpdatedEvent }); } +@internal +class SignalSubscriptionPermissionUpdateEvent + with SignalEvent, EngineEvent, InternalEvent { + final String participantSid; + final String trackSid; + final bool allowed; + const SignalSubscriptionPermissionUpdateEvent({ + required this.participantSid, + required this.trackSid, + required this.allowed, + }); +} + // ---------------------------------------------------------------------- // Engine events // ---------------------------------------------------------------------- diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 3c04b28..bc5e38e 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -3,6 +3,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; import '../core/room.dart'; +import '../core/signal_client.dart'; import '../events.dart'; import '../exceptions.dart'; import '../extensions.dart'; @@ -304,4 +305,30 @@ class LocalParticipant extends Participant { } } } + + /// Control who can subscribe to LocalParticipant's published tracks. + /// + /// By default, all participants can subscribe. This allows fine-grained control over + /// who is able to subscribe at a participant and track level. + /// + /// Note: if access is given at a track-level (i.e. both [allParticipantsAllowed] and + /// [ParticipantTrackPermission.allTracksAllowed] are false), any newer published tracks + /// will not grant permissions to any participants and will require a subsequent + /// permissions update to allow subscription. + /// + /// [allParticipantsAllowed] Allows all participants to subscribe all tracks. + /// Takes precedence over [trackPermissions] if set to true. + /// By default this is set to true. + /// + /// [trackPermissions] Full list of individual permissions per + /// participant/track. Any omitted participants will not receive any permissions. + + void setTrackSubscriptionPermissions({ + required bool allParticipantsAllowed, + List trackPermissions = const [], + }) => + room.engine.signalClient.sendUpdateSubscriptionPermissions( + allParticipants: allParticipantsAllowed, + trackPermissions: trackPermissions.map((e) => e.toPBType()).toList(), + ); } diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index f4a7206..2fa589d 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -42,6 +42,10 @@ class RemoteTrackPublication // latest TrackInfo bool _metadataMuted = false; + // allowed to subscribe + bool _subscriptionAllowed = true; + bool get subscriptionAllowed => _subscriptionAllowed; + @internal Future updateStreamState(StreamState streamState) async { // return if no change @@ -208,11 +212,27 @@ class RemoteTrackPublication _sendUpdateTrackSettings(); } - set subscribed(bool newValue) { - logger.fine('setting subscribed = ${newValue}'); - if (newValue == super.subscribed) return; - _sendUpdateSubscription(subscribed: newValue); - if (!newValue && track != null) { + @Deprecated('use subscribe() or unsubscribe() instead') + set subscribed(bool val) { + logger.fine('setting subscribed = ${val}'); + val ? subscribe() : unsubscribe(); + } + + Future subscribe() async { + if (super.subscribed || !_subscriptionAllowed) { + logger.fine('ignoring subscribe() request...'); + return; + } + _sendUpdateSubscription(subscribed: true); + } + + Future unsubscribe() async { + if (!super.subscribed || !_subscriptionAllowed) { + logger.fine('ignoring unsubscribe() request...'); + return; + } + _sendUpdateSubscription(subscribed: false); + if (track != null) { // Ideally, we should wait for WebRTC's onRemoveTrack event // but it does not work reliably across platforms. // So for now we will assume remove track succeeded. @@ -222,7 +242,7 @@ class RemoteTrackPublication publication: this, )); // Simply set to null for now - updateTrack(null); + await updateTrack(null); } } @@ -245,4 +265,51 @@ class RemoteTrackPublication } participant.room.engine.signalClient.sendUpdateTrackSettings(settings); } + + @internal + // Update internal var and return true if changed + Future updateSubscriptionAllowed(bool allowed) async { + if (_subscriptionAllowed == allowed) return false; + _subscriptionAllowed = allowed; + + logger.fine('updateSubscriptionAllowed allowed: ${allowed}'); + // emit events + [ + participant.events, + participant.room.events, + ].emit(TrackSubscriptionPermissionChangedEvent( + participant: participant, + trackPublication: this, + state: subscriptionState, + )); + + if (!_subscriptionAllowed && super.subscribed /* track != null */) { + // Ideally, we should wait for WebRTC's onRemoveTrack event + // but it does not work reliably across platforms. + // So for now we will assume remove track succeeded. + [participant.events, participant.room.events].emit(TrackUnsubscribedEvent( + participant: participant, + track: track!, + publication: this, + )); + // Simply set to null for now + await updateTrack(null); + } + + return true; + } + + @override + bool get subscribed { + // always return false when subscription is not allowed + if (!_subscriptionAllowed) return false; + return super.subscribed; + } + + TrackSubscriptionState get subscriptionState { + if (!_subscriptionAllowed) return TrackSubscriptionState.notAllowed; + return super.subscribed + ? TrackSubscriptionState.subscribed + : TrackSubscriptionState.unsubscribed; + } } diff --git a/lib/src/types.dart b/lib/src/types.dart index 0fb3943..52927ae 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -45,6 +45,12 @@ enum TrackSource { screenShareAudio, } +enum TrackSubscriptionState { + unsubscribed, + subscribed, + notAllowed, +} + /// The state of track data stream. /// This is controlled by server to optimize bandwidth. enum StreamState { @@ -159,3 +165,22 @@ class VideoDimensions { height ?? this.height, ); } + +@immutable +class ParticipantTrackPermission { + /// The participant id this permission applies to. + final String participantSid; + + /// If set to true, the target participant can subscribe to all tracks from the local participant. + /// Takes precedence over [allowedTrackSids]. + final bool allTracksAllowed; + + /// The list of track ids that the target participant can subscribe to. + final List allowedTrackSids; + + const ParticipantTrackPermission( + this.participantSid, + this.allTracksAllowed, + this.allowedTrackSids, + ); +}